- 投稿日:2020-01-13T22:44:04+09:00
【Rails】destroyメソッドを使用しようとしたら"ArgumentError (wrong number of arguments (given 0, expected 1)):"
はじめに
Railsの
destroy
メソッドを使用しようとしたら以下のように引数が1つ必要なのに見つかりませんとエラーが発生しました。
凡ミスですが、記録として残します。ArgumentError (wrong number of arguments (given 0, expected 1)):環境
OS: macOS Catalina 10.15.1 Ruby: 2.6.5 Rails: 6.0.2.1結論:解決法
今回のコードは以下のようになっていました。
def destroy posts = Post.where(user_id: 1) posts.destroy #ここでエラー発生 end↓
def destroy post = Post.find_by(user_id: 1) post.destroy #これは通る endこのように、
find_by
にすると通ります。もしくは、該当データが複数ある場合は
def destroy Post.destroy_by(user_id: 1) #これも通る endのように
destroy_by
メソッドを使えば該当データをまとめて削除することが出来ます。原因:
destroy
は配列を処理できない
where
だと該当するデータが1件であっても配列で返すようになっています。そのため、配列を処理できない
destroy
は引数が見つかりませんとエラーを吐いてしまっていたんですねおわりに
最後まで読んで頂きありがとうございました
どなたかの参考になれば幸いです
参考にさせて頂いたサイト(いつもありがとうございます)
- 投稿日:2020-01-13T22:33:02+09:00
個人開発 Web アプリの認証周りを Auth0 に移行した
個人開発している Ruby on Rails アプリ LiveLog の認証周りを Auth0 に移行しました。
この記事では、移行に関して以下を紹介します。
- 背景: なぜ移行したか
- 方法: どうやって移行したか
- 結果: 移行してどうだったか
背景: なぜ移行したか
LiveLog について
はじめに、対象の Web アプリについて簡単に紹介します。
LiveLog https://livelog.ku-unplugged.net/ は、私が所属していた軽音サークルのセットリスト管理アプリです。2016年に CakePHP から Ruby on Rails にリプレースし、それ以来 Heroku Hobby Dyno + Heroku Postgres で稼働しています。
Ruby on Rails チュートリアル に沿って開発したので、認証周りは devise gem ではなく、Rails のhas_secure_password
を使って実装していました。サークルのメンバーまたは OB・OG のみがユーザー登録でき、その登録数は2020年1月現在361です。
ユーザー登録は招待制で、既存のユーザーが LiveLog 上でメールアドレスを入力して新ユーザーを招待します。
ログインすると、外部に公開されていないライブ動画や音源を視聴できます。ユーザー周りの課題
先日サークルの OB・OG 会に参加したとき、サークル員向けの Web アプリを新しくつくったという話を現役生から聞きました。
ただ、LiveLog とは別でユーザー管理しているので、以下のような課題があります。
- サークルのメンバーはそれぞれのアプリでユーザー登録・ログイン・ユーザー情報の編集を行う必要があり面倒
- サークル内アプリ間でユーザーを突き合わせできず、連携が困難
また、LiveLog 単体でもセキュリティに不安がありました。
LiveLog はメールアドレス等の個人情報を保持していますが、認証周りはチュートリアルに則った単純な実装になっています。
セキュリティホールがないとは言い切れず、もしもの際に個人情報漏洩のリスクがあります。
年々ユーザーが増えるに従って、このリスクを減らしたいという気持ちが強くなってきていました。これらの課題を解決するため、LiveLog の認証周りを Auth0 へ移行することにしました。
なぜ Auth0 か
Auth0 https://auth0.com/ は、IDaaS と呼ばれる認証・認可周りの機能を提供するクラウドサービスのひとつです。
以下のような理由から Auth0 を利用することにしました。
- 無料プランでも LiveLog の要件を十分にカバーできそうだった1
- 招待制なのでユーザー数が上限を超えることはなく、無料プランの制限でも現状の実装よりずっと高機能そうだった: https://auth0.com/pricing/
- 招待制を実現する公式のドキュメントがあった: https://auth0.com/docs/design/creating-invite-only-applications
- 既存のデータベースからマイグレーションする方法が詳細に説明されていて、ユーザーに負担をかけることなく移行できそうだった
- Ruby および Rails が公式でサポートされており、実装が簡単そうだった
- Heroku Add-ons として提供されていた
- 最近よく噂を耳にするので一度使ってみたかった
方法: どうやって移行したか
移行の基本戦略は次のとおりです。
ここでいう認証情報は、メールアドレスとパスワードを指します。
- 認証情報の書き込み(招待・メールアドレス/パスワード変更)を止める
- 認証情報を Auth0 にインポートする
- 認証情報の読み込み(ログイン・メール送信)を Auth0 に切り替える
- 認証情報の書き込みを Auth0 に置き換えて再開する
移行中にデータの変更を止められるのは、身内向けの小規模アプリだからこそですね。
丁寧にやるなら、移行状態に応じてうまくハンドリングしながら並行して書き込みを行うような工夫が必要だと思います。
ただ、それは手間がかかるので、今回はサービスの機能を一部停止して一気にガッと切り替える方針を取ることにしました。以下、それぞれについて詳しく説明します。
認証情報の書き込みを止める
書き込みを止めるのは、データのインポート後 Auth0 への切り替え前に更新が行われ、Auth0 のデータが古いままになるという事態を避けるためです。
また、移行中に何かあって切り戻す際にも、データの変更がないとわかっていれば安心して切り戻せます。認証情報を Auth0 にインポートする
Auth0 へのデータ移行には主に次の2つの方法があります。
- Automatic Migration: https://auth0.com/docs/users/guides/configure-automatic-migration
- Bulk Import: https://auth0.com/docs/users/guides/bulk-user-imports
今回のケースでは、まず Bulk Import を行い、そこでインポートに失敗したユーザーのみ Automatic Migration で移行するという方法を取りました。
Bulk Import
認証情報のデータ移行で問題になるのが、パスワードのハッシュ化関数です。
移行前後のシステムで同じハッシュ化関数を用いていないと、パスワードの検証ができなくなります。
Auth0 の Bulk Import では、bcrypt でハッシュ化したパスワードしかインポートできないようになっています。
rails のhas_secure_password
は bcrypt を利用する実装になっていたので、この点は心配ありませんでした。Bulk Import には次のようなバッチスクリプトを書きました。
色々と前提をすっ飛ばしてますが、雰囲気は伝わるかなと思います。CONNECTION_ID = 'con_***' auth0_client = Auth0Client.new( client_id: ENV['AUTH0_RUBY_CLIENT_ID'], client_secret: ENV['AUTH0_RUBY_CLIENT_SECRET'], domain: ENV['AUTH0_RUBY_DOMAIN'], ) User.find_in_batches(batch_size: 50) do |users| puts "Processing users: #{users.map(&:id).join(',')}" users_json = users.map do |user| { user_id: user.id.to_s, # user_id must be string email: user.email, email_verified: true, password_hash: user.password_digest, } end.to_json # https://github.com/auth0/ruby-auth0/blob/79f5a27abe2f2f5d0b4624548e559669d1c99a40/spec/integration/lib/auth0/api/v2/api_jobs_spec.rb#L27-L28 file_path = Rails.root.join("tmp/auth0_import_users_#{users.first.id}-#{users.last.id}.json") File.open(file_path, 'w+') { |file| file.write(users_json) } File.open(file_path, 'rb') do |file| job = auth0_client.import_users(file, CONNECTION_ID) puts "Created import job: #{job}" end sleep 1 # for rate limit endしかし、このスクリプトを実行したところ、一部のユーザーのインポートに失敗しました。
エラーメッセージはすべて同じです。Error in passwdHash property - String does not match pattern ^\$2[ab]?\$10+\$[./A-Za-z0-9]{53}$
たしかに、失敗したユーザーの
password_digest
カラムを見ると$2a$12$...
のようになっており、パターン中の10
のところが12
になっています。
ドキュメント をよく見ると、password_hash について以下の説明がありました。Passwords should be hashed using bcrypt \$2a\$ or \$2b\$ and have 10 saltRounds.
saltRounds はハッシュ化時の計算コストを決めるパラメータです。
has_secure_password
で利用している bcrypt gem は v3.1.13 でデフォルトのコストを10から12に上げており、LiveLog では 2019/6/8 に bcrypt gem を v3.1.13 に上げる変更をデプロイしていました。
このため、6/8 以降にユーザー登録またはパスワード変更を行ったユーザーだけ Auth0 にインポートできませんでした。Automatic Migration
Automatic Migration は、個々のユーザーのログイン時にマイグレーションを行います。
Auth0 にデータがなければ既存のデータベースの情報を用いてログインを試み、成功すれば Auth0 にデータを保存するという方法です。
https://auth0.com/docs/users/concepts/overview-user-migration#automatic-migrations の図がわかりやすいです。
ユーザーが入力した平文のパスワードを利用して移行するので、bcrypt 以外のハッシュ化関数を用いていても移行できるのがポイントです。LiveLog のデータベースにアクセスできるよう、Auth0 側でスクリプトを設定します。
以下は Auth0 で用意されている PostgreSQL 用のテンプレートを少し書き換えたものです。function login(email, password, callback) { //this example uses the "pg" library //more info here: https://github.com/brianc/node-postgres const bcrypt = require('bcrypt'); const postgres = require('pg'); postgres.connect(configuration.DATABASE_URL, function (err, client, done) { if (err) return callback(err); const query = 'select id, email, password_digest from users where email = $1'; client.query(query, [email], function (err, result) { // NOTE: always call `done()` here to close // the connection to the database done(); if (err || result.rows.length === 0) return callback(err || new WrongUsernameOrPasswordError(email)); const user = result.rows[0]; bcrypt.compare(password, user.password, function (err, isValid) { if (err || !isValid) return callback(err || new WrongUsernameOrPasswordError(email)); return callback(null, { user_id: user.id.toString(), email: user.email, email_verified: true }); }); }); }); }認証情報の読み込みを Auth0 に切り替える
ログイン・ログアウトは https://auth0.com/docs/quickstart/webapp/rails/01-login に沿って置き換えました。
ただし、ログイン中のユーザーに影響がないよう、ログイン成功時セッションに保存する内容は以前と同じままにします。
セッションにはもともと user_id しか入れていませんでした。class Auth0Controller < ApplicationController # GET /auth/auth0/callback def callback auth = request.env['omniauth.auth'] user = User.find(auth.uid.match(/auth0\|(?<id>\d+)/)[:id]) user.activate! unless user.activated? session[:user_id] = user.id redirect_to root_path, notice: 'ログインしました' end endメールアドレスは Auth0 Management API を使って取得するようにします。
https://auth0.com/docs/api/management/v2#!/Users/get_users_by_id
マイグレーションが完了していないユーザーのため、Auth0 のユーザーが見つからなかった場合はデータベースから取得します。class User < ApplicationRecord # ... def fetch_email $auth0_client.user("auth0|#{id}", fields: 'email')['email'] rescue Auth0::NotFound email end # ... end認証情報の書き込みを Auth0 に置き換えて再開する
ここまで作業したあと、数日様子を見ました。
認証情報の変更が起こらない現状であれば、LiveLog と Auth0 とでデータに差分がありません。
そのため、何か不具合があった場合も、まだ容易に切り戻すことができます。
……のはずだったのですが、Auth0 側でパスワードリセットできることを考慮しておらず、気づいたときには一部のユーザーがすでにパスワードを変更していました。2
幸い、大きな不具合はなかったので、認証情報の書き込みも Auth0 に置き換えていくことにしました。ユーザーの招待は、Auth0 ユーザーの作成とパスワードリセットを同時に行うことで実現しました。
パスワードリセットには、Authentication API を用いるものと Management API を用いるものがあります。
- https://auth0.com/docs/api/authentication#change-password
- https://auth0.com/docs/api/management/v2#!/Tickets/post_password_change
前者は、パスワードを忘れた場合等に Auth0 上でパスワードリセットの手続きを踏んだ場合と同じメールが即座に送信されます。
後者は、API を叩くとパスワードリセットに進む URL が返るので、それを使って独自のメールを送信できます。
以下のコード例では前者を用いています。class InvitationsController < ApplicationController CONNECTION_NAME = 'Username-Password-Authentication' before_action :require_current_user # POST /users/:user_id/invitations def create @user = User.inactivated.find(params[:user_id]) if @user.invitations.empty? $auth0_client.create_user( nil, connection: CONNECTION_NAME, user_id: @user.id.to_s, email: params[:email], password: SecureRandom.base58, verify_email: false, ) else $auth0_client.patch_user( "auth0|#{@user.id}", email: params[:email], verify_email: false, ) end $auth0_client.change_password(params[:email], nil) # send a change password email @user.invitasions.create!(inviter: current_user) redirect_to @user, notice: '招待しました' rescue Auth0::BadRequest => e @user.errors.add(:base, t("auth0.error.#{JSON.parse(e.message)['errorCode']}", default: '招待に失敗しました')) render :new end endメールアドレスの変更は Management API を使って行います。
https://auth0.com/docs/api/management/v2#!/Users/patch_users_by_id結果: 移行してどうだったか
2019年1月現在、Automatic Migration は完了しておらず、移行のきっかけとなった複数アプリでの利用も実現していません。
まだまだこれから直面する問題や、気づいていない便利機能等があるかもしれませんが、現時点での変化について簡単に紹介します。
ただ、Auth0 を利用すること自体の利点は auth0.com や各種記事等で紹介されているので、ここでは個人的に意外だったポイントに焦点を絞ります。認証周りの分離によるアプリケーションのシンプル化
認証周りの分離により、思っていた以上にコードやテーブル、カラムが削除できました。
Rails チュートリアルがそのほとんどを認証周りの実装に費やしていることを考えると、初期のコードの大部分を消し去れたと思います。
なかでも、users
テーブルとUser
モデルの見通しがよくなったのは大きいです。
これらが整理された結果、LiveLog でフォーカスすべき機能がより明確になった感じがします。ダッシュボードによる可視化
ダッシュボードでは、1日あたりのログイン回数が GitHub の草風の見た目でわかるようになっています。
https://auth0.com/docs/getting-started/dashboard-overview無料プランでは2日分だけですが、ログイン成功・失敗やパスワード変更のログも保存されています。
移行直後は定期的にログ一覧をみて、ログインできないまま諦めているユーザーがいないか監視していました。個人開発ではなかなかログやダッシュボードに手が回らないので、こうしたデータが見られるのは嬉しいですね。
開発環境の複雑化
仕方ないことではあるのですが、これがデメリットでしょうか。
開発環境用の Tenant を作成し、環境変数を設定しないとローカルで認証周りがまともに動きません。
一度設定してしまえば大したことはないのですが、最初の環境構築のハードルは上がりますね。おわりに
以上、LiveLog における認証周りの Auth0 移行について紹介しました。
- 個人開発の小規模 Ruby on Rails アプリケーション (not SPA)
- ユーザー登録は招待制
- ログイン方法はメールアドレス+パスワードのみ
- 一部機能停止を伴う移行方法
という特殊な事例でしたが、参考になれば幸いです。
Custom Domains が利用できないのは手痛いですが、メリットの方が大きいので妥協しました。 ↩
ユニバーサルログインの使用でログイン画面のドメインが
***.auth0.com
になり、ユーザー側でブラウザのパスワード自動入力が効かなくなっていたため、少なくないユーザーがログインに失敗してパスワード変更していました。 ↩
- 投稿日:2020-01-13T22:08:20+09:00
railsのデータベースをmysqlにする
rails アプリ作成時
rails new アプリケーション名 -d mysqlデータベース設定ファイル config/database.yml
database.ymldefault: &default adapter: mysql2 encoding: utf8 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> username: **** password: **** host: 127.0.0.1 development: <<: *default database: データベース名scaffoldを使ってテストをする
- scaffoldとはCRUD機能を簡単にテストすることのできるrailsの機能
rails g scaffold モデル名 カラム名:型http://localhost:3000/モデル名s
でアクセスするとテストページに飛ぶ
- 投稿日:2020-01-13T16:52:51+09:00
【実践】SolidusでRails製ECサイトを作ってみる
SolidusはSpreeの後継のRails製オープンソースECサイトのプロジェクト。
OSSを使わないとしても、ECに最低限の機能が入ってるので、自分たちで機能の洗い出しするより、これを参考にした方が早い。準備
以下をインストールしていない人はインストール
Rubyはバージョンが低いとfiniteのエラーが出ます。
テスト環境ではv2.5.1を使用しました。brew install sqlite3 imagemagick1. Railsアプリを作成
rails new myshop --skip_webpack_install cd myshop2. Gemfileに以下を追加
Gemfilegem 'solidus' gem 'solidus_auth_devise''solidus'をrequireすると、以下がまとめてインストールされます。個別にインストールすることも可能なよう。
solidus_core
solidus_api
solidus_frontend
solidus_backend
solidus_sample
bundle install
3. gemのinitialize & マイグレーション
bundle exec rails generate spree:install # ここでadmin / パスワードを設定します bundle exec rails generate solidus:auth:install bundle exec rake railties:install:migrations bundle exec rake db:migratespreeはsolidusの元のOSS eCommerceプロジェクトです。
マイグレーションも作成されます。4.Viewをオーバーライドする
bundle exec rails generate solidus:views:override # bundle exec rails generate solidus:views:override --only products/show # 一部だけカスタマイズする場合はこのようなコマンド起動してみる
bundle exec rails server管理画面について
adminユーザーでログインし、
/admin
にアクセスすると、管理画面が表示されます。APIについて
APIリファレンスはこちら
https://solidus.docs.stoplight.io/商品のレスポンス例:
{ "count": 18, "total_count": 18, "current_page": 1, "pages": 1, "per_page": 25, "products": [ { "id": 1, "name": "Ruby on Rails Tote", "description": "Soluta sed error debitis repellendus et. Voluptates unde enim qui velit. Libero earum tenetur nulla similique temporibus quod repellendus quibusdam.", "available_on": "2020-01-13T07:32:59.433Z", "slug": "ruby-on-rails-tote", "meta_description": null, "meta_keywords": null, "shipping_category_id": 1, "taxon_ids": [ 3, 10, 13, 20 ], "meta_title": null, "total_on_hand": 10, "price": "15.99", "display_price": "$15.99", "has_variants": false, "master": { "id": 1, "name": "Ruby on Rails Tote", "sku": "ROR-00011", "weight": "0.0", "height": null, "width": null, "depth": null, "is_master": true, "slug": "ruby-on-rails-tote", "description": "Soluta sed error debitis repellendus et. Voluptates unde enim qui velit. Libero earum tenetur nulla similique temporibus quod repellendus quibusdam.", "track_inventory": true, "cost_price": "17.0", "price": "15.99", "display_price": "$15.99", "options_text": "", "in_stock": true, "is_backorderable": true, "total_on_hand": 10, "is_destroyed": false, "option_values": [], "images": [] }, "variants": [], "option_types": [], "product_properties": [ { "id": 25, "product_id": 1, "property_id": 9, "value": "Tote", "property_name": "Type" }, { "id": 26, "product_id": 1, "property_id": 10, "value": "15\" x 18\" x 6\"", "property_name": "Size" }, { "id": 27, "product_id": 1, "property_id": 11, "value": "Canvas", "property_name": "Material" } ], "classifications": [ { "taxon_id": 3, "position": 1, "taxon": { "id": 3, "name": "Bags", "pretty_name": "Categories -> Bags", "permalink": "categories/bags", "parent_id": 1, "taxonomy_id": 1, "taxons": [] } }, { "taxon_id": 10, "position": 1, "taxon": { "id": 10, "name": "Rails", "pretty_name": "Brand -> Rails", "permalink": "brand/rails", "parent_id": 2, "taxonomy_id": 2, "taxons": [] } }, { "taxon_id": 13, "position": 1, "taxon": { "id": 13, "name": "Bags", "pretty_name": "Categories -> Bags", "permalink": "categories/bags", "parent_id": 1, "taxonomy_id": 1, "taxons": [] } }, { "taxon_id": 20, "position": 1, "taxon": { "id": 20, "name": "Rails", "pretty_name": "Brand -> Rails", "permalink": "brand/rails", "parent_id": 2, "taxonomy_id": 2, "taxons": [] } } ] }, ...まとめ
詳しくはもう少しいじって追記していく予定。
- 投稿日:2020-01-13T16:42:25+09:00
rails: 絶対に分かるhas_one :through関連付ける方法
前提
ユーザー・予約・住所の3つのレコードで
has_one :through
を使用し、アソシエーション関係を作りたいと思います。実際のコード
class Booking < ApplicationRecord # ユーザーテーブルに対して一対一の関係を示している has_one :user # has_one :addressはユーザーテーブルにある has_one :addressのことである。 # throughは上記に書かれているhas_one :userのことである has_one :address, through: :user endclass User < ApplicationRecord has_one :address, dependent: :destroy endclass Address < ApplicationRecord belongs_to :user end要約
要約すると、
has_one through
のhas_one
はアソシエーション先のhas_one :address
のことであり、through
は自モデル内に書かれているhas_one :user
のことである出力結果
@booking = Booking.find_by(id: 1) @booking.address.zip_code # => 150-0002
- 投稿日:2020-01-13T16:38:29+09:00
トランザクションのネストの使い方まとめた(初心者向け)
トランザクションのネストについてまとめてみました
どう記述したらネストができるの?
ロールバックした時の挙動は?
などなどまとめてみました
自分がよく使うMySQLとRails(ActiveRecord)について記載します。他のDBやフレームワークでは多分話が変わりますのでご注意ください前提
ネストしたトランザクションの挙動
ネストしたトランザクションって、正確な挙動がこうあるべきという決まりがあるのかどうかは筆者はよく知りません
ここでは、以下のような挙動を満たすことを目的にします
- トランザクションの内部に、もう一つトランザクションを貼る
- 内側のトランザクションがロールバックした場合、外側のトランザクションには影響を与えない
- 外側のトランザクションがロールバックした場合、内側のトランザクションもロールバックする
- 内側だけコミットされてしまうと、外側のトランザクションから見ると一貫性が破綻することになるため
- つまり「内側だけコミットする」はナシ。「内側だけロールバックする」はアリ
テストデータ
この記事では、以下のような
users
テーブルを使用して実験していますmysql> show create table users; +-------+-------------------------------------------------------------------------------------------------------------------------------------------------------------+ | Table | Create Table | +-------+-------------------------------------------------------------------------------------------------------------------------------------------------------------+ | users | CREATE TABLE `users` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 | +-------+-------------------------------------------------------------------------------------------------------------------------------------------------------------+MySQL におけるトランザクションのネスト
BEGINを2回書く(ダメな方法)
トランザクションの開始は
BEGIN
です。
じゃあBEGIN
の中でもう一回BEGIN
を書いてみたらどうなる?正常系
まずは、ネストした上で正常にコミットさせてみます
BEGIN; INSERT INTO users VALUES(1, 'before-nest'); BEGIN; INSERT INTO users VALUES(2, 'in-nest'); COMMIT; INSERT INTO users VALUES(3, 'after-nest'); COMMIT; select * from users; +----+-------------+ | id | name | +----+-------------+ | 1 | before-nest | | 2 | in-nest | | 3 | after-nest | +----+-------------+(みやすいようにインデントしてますが本来は不要です)
問題ないようです
(※が、実は問題があります。後述)ネストの中でロールバックする
BEGIN; INSERT INTO users VALUES(1, 'before-nest'); BEGIN; INSERT INTO users VALUES(2, 'in-nest'); ROLLBACK; # <===ココ INSERT INTO users VALUES(3, 'after-nest'); COMMIT; select * from users; +----+-------------+ | id | name | +----+-------------+ | 1 | before-nest | | 3 | after-nest | +----+-------------+ネスト内の INSERT 文だけロールバックされています
どうやら正しく動いているようです
(※が、 これも実は問題あり なのです。後述)ネストの後でロールバックする
BEGIN; INSERT INTO users VALUES(1, 'before-nest'); BEGIN; INSERT INTO users VALUES(2, 'in-nest'); COMMIT; INSERT INTO users VALUES(3, 'after-nest'); ROLLBACK; # <=== ココ select * from users; +----+-------------+ | id | name | +----+-------------+ | 1 | before-nest | | 2 | in-nest | | 3 | after-nest | +----+-------------+おかしい!ロールバックされてない
外側のトランザクションでロールバックが起きたわけですから、内側(in-nest
)も含めて全部ロールバックされていないとおかしいわけですBEGIN はネストできない
MySQL では、トランザクションはネストすることができないのです
実は、2度目のBEGIN
が実行された時、現在のトランザクションがコミットされ、新しいトランザクションが開始しているんです。このことは公式ドキュメントにも書いてあります。
https://dev.mysql.com/doc/refman/5.6/ja/implicit-commit.htmlトランザクションをネストすることはできません。これは、START TRANSACTION ステートメントまたはそのシノニムのいずれかを発行するときに、現在のすべてのトランザクションに対して実行される暗黙的なコミットの結果です。
つまり、2度目の
BEGIN
の時点で、before-nest
のインサート文がコミットされます。ネストのCOMMIT;
の時点でin-nest
もコミットされますよね。そしてafter-nest
は、トランザクション外で実行されているわけです。だからその後ROLLBACK;
が実行されようがロールバックされません。
COMMIT;
ROLLBACK;
って、トランザクション貼ってない時に実行しても特にエラーとか起こさないんですねえ。ということから、トランザクションネストしたい時に
BEGIN
は使っちゃダメということがわかりましたSAVEPOINT を使う(正しい方法)
というわけで、こういう時は
SAVEPOINT
命令を使います。
外側のトランザクションはBEGIN
のままでよく、内側のトランザクションはSAVEPOINT
と書きます。書き方は以下のように対応しています
BEGIN文 SAVEPOINT文 BEGIN SAVEPOINT hoge COMMIT RELEASE SAVEPOINT hoge ROLLBACK ROLLBACK TO SAVEPOINT hoge SAVEPOINT はいくつも同時に貼ることができるため、必ず命令文の後に名前を指定する形になります
正常系
BEGIN; INSERT INTO users VALUES(1, 'before-nest'); SAVEPOINT nest; # <===ココ INSERT INTO users VALUES(2, 'in-nest'); RELEASE SAVEPOINT nest; # <===ココ INSERT INTO users VALUES(3, 'after-nest'); COMMIT; select * from users; +----+-------------+ | id | name | +----+-------------+ | 1 | before-nest | | 2 | in-nest | | 3 | after-nest | +----+-------------+ちゃんとインサートされていますね
ネストの中でロールバックする
BEGIN; INSERT INTO users VALUES(1, 'before-nest'); SAVEPOINT nest; INSERT INTO users VALUES(2, 'in-nest'); ROLLBACK TO SAVEPOINT nest; # <===ココ INSERT INTO users VALUES(3, 'after-nest'); COMMIT; select * from users; +----+-------------+ | id | name | +----+-------------+ | 1 | before-nest | | 3 | after-nest | +----+-------------+内側だけロールバックしていますね
ネストのあとでロールバックする
BEGIN; INSERT INTO users VALUES(1, 'before-nest'); SAVEPOINT nest; INSERT INTO users VALUES(2, 'in-nest'); RELEASE SAVEPOINT nest; INSERT INTO users VALUES(3, 'after-nest'); ROLLBACK; # <===ココ select * from users; Empty set (0.00 sec)全部ロールバックしています!
SAVEPOINT
を使うと、ネストトランザクションが正しく動作することが確認できました。ActiveRecord におけるトランザクションのネスト
さて今度は ActiveRecord でトランザクションを記述したらどういうクエリが発行されるかの確認です
DBは引き続きMySQLです何も考えず
.transaction
してみる(ダメな方法)正常系
トランザクションをネストさせたコードを書きます
それによって実行されたクエリとDBの中身を続けて書いています。User.transaction do User.create!(id: 1, name: 'before-nest') User.transaction do User.create!(id: 2, name: 'in-nest') end User.create!(id: 3, name: 'after-nest') endBEGIN INSERT INTO `users` (`id`, `name`) VALUES (1, 'before-nest') INSERT INTO `users` (`id`, `name`) VALUES (2, 'in-nest') INSERT INTO `users` (`id`, `name`) VALUES (3, 'after-nest') COMMIT +----+-------------+ | id | name | +----+-------------+ | 1 | before-nest | | 2 | in-nest | | 3 | after-nest | +----+-------------+こうなりました。
クエリを見て分かる通り、 内側のUser.transaction
は何のクエリも発行していないということがわかりましたこれは当然、ネストしたトランザクションを使うという目的は満たしていませんね。
ネストの中でロールバックした場合
ロールバックした時の挙動もチェックしておきましょう
トランザクションを明示的にロールバックさせるためには、 ActiveRecord::Rollback 例外を投げます
どんな例外を投げてもロールバックされるでしょ?と思われる方もいるかもしれませんが、ちょっと違うんです(後述)User.transaction do User.create!(id: 1, name: 'before-nest') User.transaction do User.create!(id: 2, name: 'in-nest') raise ActiveRecord::Rollback # <===ココ end User.create!(id: 3, name: 'after-nest') endBEGIN INSERT INTO `users` (`id`, `name`) VALUES (1, 'before-nest') INSERT INTO `users` (`id`, `name`) VALUES (2, 'in-nest') INSERT INTO `users` (`id`, `name`) VALUES (3, 'after-nest') COMMIT mysql> select * from users; +----+-------------+ | id | name | +----+-------------+ | 1 | before-nest | | 2 | in-nest | | 3 | after-nest | +----+-------------+ロールバックされないという不思議な結果になりました。
理由は後述しますネストの後でロールバックした場合
User.transaction do User.create!(id: 1, name: 'before-nest') User.transaction do User.create!(id: 2, name: 'in-nest') end User.create!(id: 3, name: 'after-nest') raise ActiveRecord::Rollback # <===ココ endBEGIN INSERT INTO `users` (`id`, `name`) VALUES (1, 'before-nest') INSERT INTO `users` (`id`, `name`) VALUES (2, 'in-nest') INSERT INTO `users` (`id`, `name`) VALUES (3, 'after-nest') ROLLBACK mysql> select * from users; Empty set (0.03 sec)全部ロールバックされます。
まあ、これはわかりますよね。トランザクション1つしかないんだし…どうやら、 ActiveRecoed は普通にtransactionメソッドをネストしてもうまくいかないようです
クエリは発行されないし、ロールバックもしてくれなかったりと挙動もヘンです
どうしたらいいのでしょうか(答えはこの後すぐ)オプション
requires_new
transactionメソッドには、
requires_new
というオプションがあります。
これを指定すると、明示的に新しいトランザクションを貼ることができるのです。
やってみましょう正常系
User.transaction do User.create!(id: 1, name: 'before-nest') User.transaction(requires_new: true) do # <===ココ User.create!(id: 2, name: 'in-nest') end User.create!(id: 3, name: 'after-nest') endBEGIN INSERT INTO `users` (`id`, `name`) VALUES (1, 'before-nest') SAVEPOINT active_record_1 INSERT INTO `users` (`id`, `name`) VALUES (2, 'in-nest') RELEASE SAVEPOINT active_record_1 INSERT INTO `users` (`id`, `name`) VALUES (3, 'after-nest') COMMIT mysql> select * from users; +----+-------------+ | id | name | +----+-------------+ | 1 | before-nest | | 2 | in-nest | | 3 | after-nest | +----+-------------+ 3 rows in set (0.00 sec)
requires_new: true
を指定すると、SAVEPOINT
クエリが発行されましたね! (自動でactive_record_1
という名前がつけられています)
トランザクションブロックが終了した時も、ちゃんとRELEASE SAVEPOINT
が発行されています
正しく動いていますねネストの中でロールバックした場合
User.transaction do User.create!(id: 1, name: 'before-nest') User.transaction(requires_new: true) do User.create!(id: 2, name: 'in-nest') raise ActiveRecord::Rollback # <===ココ end User.create!(id: 3, name: 'after-nest') endBEGIN INSERT INTO `users` (`id`, `name`) VALUES (1, 'before-nest') SAVEPOINT active_record_1 INSERT INTO `users` (`id`, `name`) VALUES (2, 'in-nest') ROLLBACK TO SAVEPOINT active_record_1 INSERT INTO `users` (`id`, `name`) VALUES (3, 'after-nest') COMMIT mysql> select * from users; +----+-------------+ | id | name | +----+-------------+ | 1 | before-nest | | 3 | after-nest | +----+-------------+ 2 rows in set (0.00 sec)内側のトランザクションだけロールバックしています!
いい感じですねネストの後でロールバックする場合
User.transaction do User.create!(id: 1, name: 'before-nest') User.transaction(requires_new: true) do User.create!(id: 2, name: 'in-nest') end User.create!(id: 3, name: 'after-nest') raise ActiveRecord::Rollback # <=== ココ endBEGIN INSERT INTO `users` (`id`, `name`) VALUES (1, 'before-nest') SAVEPOINT active_record_1 INSERT INTO `users` (`id`, `name`) VALUES (2, 'in-nest') RELEASE SAVEPOINT active_record_1 INSERT INTO `users` (`id`, `name`) VALUES (3, 'after-nest') ROLLBACK mysql> select * from users; Empty set (0.01 sec)全部ロールバックされました!
いいですね
ちゃんとネストしたトランザクションとしての挙動をしてくれましたActiveRecord::Rollback 以外のエラーが起きた時
transaction の中で例外がおきた時、トランザクションがロールバックされることはご存知と思いますが
ActiveRecord::Rollback
エラーだけは、ちょっと挙動が違いますUser.transaction do User.create!(id: 1, name: 'before-nest') User.transaction(requires_new: true) do User.create!(id: 2, name: 'in-nest') raise # <=== ActiveRecord::Rollback でない例外が起きた end User.create!(id: 3, name: 'after-nest') endBEGIN INSERT INTO `users` (`id`, `name`) VALUES (1, 'before-nest') SAVEPOINT active_record_1 INSERT INTO `users` (`id`, `name`) VALUES (2, 'in-nest') ROLLBACK TO SAVEPOINT active_record_1 ROLLBACK # <=== ここもロールバックしている mysql> select * from users; Empty set (0.00 sec)外側のトランザクションまでロールバックしています
先ほどActiveRecord::Rollback
例外を投げた時は、内側のトランザクションだけがロールバックし、外側のトランザクションは何事もなかったかのように続行されていました
今回は、例外が内側のトランザクションを突き抜けて、外側のトランザクションまで例外によるロールバックを発生させちゃったんですね。(なんなら外側のトランザクションも突き抜けているので、どこかで捕捉しないとプログラムが止まります)
実は ActiveRecord の transaction メソッドは、ActiveRecord::Rollback
例外だけを静かに飲み込んで何事もなかったかのように振舞っているのです (https://github.com/rails/rails/blob/v6.0.2.1/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L283)これはトランザクションがネストしているかどうかとは関係なく、覚えておきたい点ですね。
さっきの答え合わせ
.transaction
を2回貼った時、内側でraise ActiveRecord::Rollback
を投げた時、ロールバックしなかったという不思議現象がありましたね
あれは、例外が内側のtransactionに捕捉され、外側のトランザクションからすると何事も起こっていないように見えていたからでした。
あのコードでは、内側の transaction メソッドは実際にはBEGIN
やSAVEPOINT
クエリを発行していないため、対応するROLLBACK
ROLLBACK TO SAVEPOINT
を発行しなかったということのようです
だったら例外も捕捉しないで上にそのまま raise してよ、という気もしますね(この挙動の方が辻褄があうと言うことなんでしょうか…?)貼ってる?トランザクション
参考までに
ActiveRecord には、今トランザクションを貼っているかどうかを知るメソッドがありますUser.connection.open_transactionsこのように書くと、現在貼っているトランザクション数が返ってきます
User.connection.open_transactions # => 0 User.transaction do User.connection.open_transactions # => 1 end User.transaction do User.transaction(requires_new: true) do User.connection.open_transactions # => 2 end end User.transaction do User.transaction do User.connection.open_transactions # => この場合は1 end endまとめ
- MySQL
- ネストしたいなら
SAVEPOINT
命令を使おうBEGIN
を2度書くと思わぬことが起きるぞ- 厳密にはトランザクションのネストはできないよ
- Rails(ActiveRecord)
- ネストしたトランザクションを貼るときは、オプション
requires_new: true
を指定しよう
- 指定しないと予想外の挙動になるよ
- 内側のトランザクションだけをロールバックする場合は、
raise ActiveRecord::Rollback
を投げよう
- それ以外の例外だと外側も一緒にロールバックしちゃうぞ
まとめのまとめ
可能な限り、トランザクションのネストってしない方がいいよ
- 投稿日:2020-01-13T16:36:12+09:00
初心者向けVue.js × Railsでのアプリ作成(ToDoリスト編)
はじめに
最近、Vue.jsとRailsでアプリを作っているのですが、Vue.jsとRailsでアプリを作る記事が少なく、勉強するのに少し不便でした。
Vue.js × Railsの記事が少ないと言っても探せばそれなりに見つかるのですが、私みたいなフロントエンドの事よくわかってない人間には、理解するのに時間がかかったりします。
ネットで記事をあさったり、そもそもJavaScriptが良くわかってないので、JavaScriptから勉強し直してみたり、Vue.js × Railsでアプリを作るだけにしては非常に遠回りしてしまいました。
この記事について
この記事は、私みたいにVue.js × Railsのアプリ作成で遠回りな勉強をしている人をなくす事を目的としています。
初心者向けにVue.js × Railsでアプリを作る記事を書いて、実装のイメージを掴んでもらえれば、私のような遠回りはなくなるはず。。。1度小さいアプリを作ってしまえば、理解度がグッと上がり、他の記事も読みやすくなるのできっと大丈夫!
また、この記事は私がRubyエンジニアなので、Rubyエンジニアから見てVue.jsをどう実装しようかという視点になってます。
この記事を読む対象のレベル
Vue.js × Rails両方ともチュートリアルをやったくらいのレベルを対象としています。
Vue.jsもRailsもだいたいこんな感じというのがわかっていれば作れると思います。どんなものを作るか? Vue.js × Railsそれぞれの役割とは?
この記事では、Vue.js × RailsでミニマムなToDoリストアプリを作っていきます。定番ですね。
私はToDoアプリを作ろうとした時、Vue.js × Railsがそれぞれどんな役割をしているのかよくわからず、実装のイメージが掴めなかったのですが、いろんな記事を読んだ結果、RailsでAPIを作り、APIへのリクエストと返ってきたデータの表示をVue.jsで行うというのが多かったです。今回もこの役割分担でアプリを作っていきます。
WEB業界での経験が浅い、もしくはこれからWEB業界を目指す方はAPIのイメージがつかみにくいかもしれませんが、一言で言うとURLのリクエストを受けたら、URLに応じたデータを返すものです。
この役割のイメージが分かればRailsの部分をFirebaseに置き換えようとか、Vue.jsをReactに置き換えようとか応用が効くような気がします。
(注)私は現時点でFirebaseもReactも詳しくないので応用が効く気がするというだけ。。。詳しい方がいたら教えて下さると助かります。
完成後のイメージ
- テキストボックスにタスクを入力して追加ボタンを押すとリストに追加されて表示される。
- チェックボックスをチェックすると取り消し線が引かれる
- 削除ボタンを押すと削除
実際に作ってみよう
RailsでAPIを作る
では、実際にアプリを作ってきます。まずはRailsでAPIを作るところから。
以下のコマンドで、Railsアプリを作ります。--webpack=vue
をするとwebpackとVue.jsのインストールが出来ます。rails new todo_list --webpack=vue「webpackとかまた新しい言葉出すなよ!」って方は以下のリンクを見てください!
【5分でなんとなく理解!】Webpack入門
Webpackとは、js、cssなどフロントで作るファイルをバンドリングしてくれるものです。ToDoリストを表示するHome画面を作成
ToDoを表示するHome画面を作るため、コントローラーを作成します。
rails g controller home作成したコントローラーにindexだけ追加しておきましょう。
/app/controllers/home_controller.rbclass HomeController < ApplicationController def index end endviewからVue.jsが呼び出せるか試しますために追加します。
/app/views/home/index.erb<%= javascript_pack_tag 'hello_vue' %>routes.rbに以下を追加。
/config/routes.rbRails.application.routes.draw do root to: 'home#index' end
rails s
してlocalhost:3000
にアクセスします。
以下のような画面が表示されてればOKです。ちなみにVue.jsを変更したら
bin/webpack
で更新してあげる必要があります。(重要)APIの処理を作る
まずは、ToDoリストにタスクを追加するためにモデルを作っていきます。
rails generate model Task name:string is_done:booleanルーティングに以下を追加します。
表示用のhomeとデータを返すAPI用のapi::tasksを追加。/config/routes.rbRails.application.routes.draw do root to: 'home#index' namespace :api, format: 'json' do resources :tasks, only: [:index, :create, :destroy, :update] end endリクエストを受けたらデータを返すため、Tasksのコントローラーを作ります。
rails g controller Api::Tasksマイグレーションします。
rails db:migrateコントローラーの中身は以下のような感じ。
/app/controllers/api/tasks_controller.rbmodule Api class TasksController < ApplicationController skip_before_action :verify_authenticity_token def index @tasks = Task.order('created_at DESC') end def create @task = Task.new(task_params) if @task.save render json: @task, status: :created else render json: @task.errors, status: :unprocessable_entity end end def destroy Task.find(params[:id]).destroy! end def update Task.find(params[:id]).toggle!(:is_done) end private def task_params params.require(:task).permit(:name, :is_done) end end endAPIを返す時は、htmlではなくJSONで返してあげたいので以下を追加します。
自分は実際にWEB業界に入るまでJSONに馴染みがなかったのですが、以下の形で書きます。/app/views/api/tasks/index.json.jbuilderjson.set! :tasks do json.array! @tasks do |task| json.extract! task, :id, :name, :is_done, :created_at, :updated_at end endAPIの動作確認
DBにデータを入れて確認してみましょう。
コンソールを立ち上げます。rails cTaskモデルにデータを追加してみましょう。
Task.create(name: 'テスト用タスク')もう一度サーバー立ち上げ
rails s以下のアドレスで追加したデータがJSONでデータが返ればOK。
http://localhost:3000/api/tasks.json
Vue.jsでフロント作成
コンポーネント
コンポーネントとは、名前付きの再利用可能な Vue インスタンスです。
再利用出来そうなパーツごとにコンポーネントを区切って実装するのが、どうやら重要らしい。
今回、最小限の構成でアプリを構成するためコンポーネントについては省こうか迷ったのですが、重要なので組み込みます。わかりやすいイメージで言うと、ヘッダー、フッター、サイドナビ等は色んなページで再利用するのでコンポーネントを分けて実装するといった感じでしょうか。
今回もヘッダーとToDoリストを表示するボディ部分でコンポーネントを分けて実装したいと思います。では、まず以下のようにToDoリスト表示部分を作って下さい。
/app/views/home/index.erb<div id="app"> <navbar></navbar> </div> <%= javascript_pack_tag 'todo' %> # todoに変更する事に注意はヘッダーのコンポーネントを表示します。
<%= javascript_pack_tag 'todo' %>でapp/javascript/packs配下のtodo.jsファイルを読み込みます。ヘッダーの作成
まずはヘッダー部分のコンポーネントを用意します。
/app/javascript/packs/components/header.vue<template> <h1>ToDoリスト</h1> </template>次に
/app/views/home/index.erb
から呼び出されているapp/javascript/packs/todo.js
にコンポーネントの設定をしていきます。
以下のように書くとapp/views/home/index.erb
の<navbar></navbar>
に/app/javascript/packs/components/header.vue
をマウントして表示してくれるようです。/app/javascript/packs/todo.jsimport Vue from 'vue/dist/vue.esm.js' import Header from './components/header.vue' var app = new Vue({ el: '#app', components: { 'navbar': Header } });Vue.jsの読み込み設定
webpackはVue.jsの読み込み方がわからないので以下を実行します(重要)
/config/loaders/stylus.jsmodule.exports = { test: /\.styl$/, use: [ 'style-loader', 'css-loader', 'stylus-loader' ] }/config/webpack/environment.jsconst { environment } = require('@rails/webpacker') const { VueLoaderPlugin } = require('vue-loader') const vue = require('./loaders/vue') const stylus = require('../loaders/stylus') // 作ったstylusをrequireする environment.plugins.prepend('VueLoaderPlugin', new VueLoaderPlugin()) environment.loaders.prepend('vue', vue) module.exports = environment environment.loaders.prepend('stylus', stylus) // 作ったstylusをロードwebpackを再読み込みしてからサーバーを再起動しましょう(重要)
bin/webpackrails srails sしてヘッダーが表示されて入ればOKです
ToDoリストを表示するボディ部分
axiosというライブラリを使って、フロントエンドからHTTPリクエストをします。
以下のコマンドでyarnでaxiosを追加して下さい。yarn add axiosToDoアプリのメイン部分の実装です。解説は後ほど詳しく説明します。
/app/javascript/packs/components/index.vue<template> <div> <div> <input v-model="newTask" placeholder="to doを追加して下さい"> <div v-on:click="createTask"> <i>追加</i> </div> </div> <ul> <li v-for="(task, index) in tasks"> <input type="checkbox" v-model="task.is_done" v-on:click="update(task.id, index)"> <span v-bind:class="{done: task.is_done}">{{ task.name }}</span> <button v-on:click="deleteTask(task.id, index)">削除</button> </li> </ul> </div> </template> <script> import axios from 'axios'; export default { data: function () { return { tasks: [], newTask: '' } }, mounted: function () { this.fetchTasks(); }, methods: { fetchTasks: function () { axios.get('/api/tasks').then((response) => { for(let i = 0; i < response.data.tasks.length; i++) { this.tasks.push(response.data.tasks[i]); } }, (error) => { console.log(error, response); }); }, createTask: function () { if(this.newTask == '') return; axios.post('/api/tasks', { task: { name: this.newTask } }).then((response) => { this.tasks.unshift(response.data); this.newTask = ''; }, (error) => { console.log(error, response); }); }, deleteTask: function (task_id, index) { axios.delete('/api/tasks/' + task_id).then((response) => { this.tasks.splice(index, 1); }, (error) => { console.log(error, response); }); }, update: function (task_id) { axios.put('/api/tasks/' + task_id).then((response) => { }, (error) => { console.log(error); }); } } } </script>作ったindex.vueを読み込んであげましょう。
/app/javascript/packs/todo.jsimport Vue from 'vue/dist/vue.esm.js' import Header from './components/header.vue' import Index from './components/index.vue' // 追加 var app = new Vue({ el: '#app', components: { 'navbar': Header, 'contents' : Index // 追加 } });ヘッダーのしたにindex.vueを表示するため、
<navbar></navbar>
の下に<contents></contents>
を追加します。/app/views/home/index.erb<div id="app"> <navbar></navbar> <contents></contents> </div> <%= javascript_pack_tag 'todo' %>チェックボックスが押されたら取り消し線を表示するためcss追加。
app/assets/stylesheets/home.scss#app li > span.done { text-decoration: line-through; }
rails s
して動くか確認してみて下さい。
実際にToDoリストを追加してみましょう。ToDoアプリのコード解説メモ
学習し始めだと、Vue.jsのどの行が何をやっているのかわからない事があったのでメモ付きのコードをのせます。
まずはtemplate
<template> <div> <div> <input v-model="newTask" placeholder="to doを追加して下さい"> # 追加ボタンを押すとcreateTaskを実行する <div v-on:click="createTask"> <i>追加</i> </div> </div> <ul> # fetchしたタスク一覧(tasks)から一つずつtaskとindexを取り出す処理 <li v-for="(task, index) in tasks"> # チェックボックスが押されたらv-modelのis_doneを変更して取り消し線を引く # updateでAPI側のデータも更新 <input type="checkbox" v-model="task.is_done" v-on:click="update(task.id, index)"> # タスクの表示。v-bind:classでis_doneを参照して取り消し線が引かれるかどうか判定 <span v-bind:class="{done: task.is_done}">{{ task.name }}</span> # 削除ボタンを押すとdeleteTaskを実行 <button v-on:click="deleteTask(task.id, index)">削除</button> </li> </ul> </div> </template><script> import axios from 'axios'; export default { data: function () { return { tasks: [], newTask: '' } }, mounted: function () { this.fetchTasks(); }, methods: { // APIからタスク一覧を取得 fetchTasks: function () { axios.get('/api/tasks').then((response) => { for(let i = 0; i < response.data.tasks.length; i++) { this.tasks.push(response.data.tasks[i]); } }, (error) => { console.log(error, response); }); }, // 新しいタスク作成 createTask: function () { // テキストボックスが空の場合はreturnして終了 if(this.newTask == '') return; // apiへ追加リクエスト axios.post('/api/tasks', { task: { name: this.newTask } }).then((response) => { // unshiftで現在のtasksの先頭にタスクを追加 this.tasks.unshift(response.data); // 追加したらテキストボックスを空にする this.newTask = ''; }, (error) => { console.log(error, response); }); }, // タスク削除 deleteTask: function (task_id, index) { // apiへ削除リクエスト axios.delete('/api/tasks/' + task_id).then((response) => { this.tasks.splice(index, 1); }, (error) => { console.log(error, response); }); }, // タスク更新。今回はis_doneのみ更新だが、タスク名とか色々更新するようカスタムしても良いと思う update: function (task_id) { // apiへ更新リクエスト axios.put('/api/tasks/' + task_id).then((response) => { }, (error) => { console.log(error); }); } } } </script>まとめ
小さいアプリをとりあえず作ってみると理解度がかなり深まると思うので、今回のようなToDoアプリを作ってみると良いと思います。
かけ足で記事を書いてしまったのですが、これで私みたいな人間を救えるのか...???
今後も私のようにVue.js × Railsでアプリを作りたい人向けに記事を改訂して行きたいです。
- 投稿日:2020-01-13T16:04:55+09:00
docker-compose up でrails サーバーを立ち上げたときのBundler::GemNotFound対策
docker-compose upしてもコンテナが落ちる。
構成
DockerFile
FROM ruby:2.5 ENV LANG C.UTF-8 ENV WORKSPACE=/usr/local/src/ # install bundler. RUN apt-get update && \ apt-get install -y sudo && \ apt-get install -y vim less && \ apt-get install -y build-essential libpq-dev&& \ gem install bundler && \ gem update && \ apt-get clean #&& \ RUN curl -sL https://deb.nodesource.com/setup_10.x | bash - \ && apt-get install -y nodejs && \ rm -r /var/lib/apt/lists/* # create user and group. RUN groupadd -r --gid 1000 rails && \ useradd -m -r --uid 1000 --gid 1000 rails # create directory. RUN mkdir -p $WORKSPACE $BUNDLE_APP_CONFIG && \ chown -R rails:rails $WORKSPACE && \ chown -R rails:rails $BUNDLE_APP_CONFIG USER rails WORKDIR $WORKSPACE # install ruby on rails. ADD --chown=rails:rails src $WORKSPACE RUN bundle install EXPOSE 3000 CMD ["rails", "server", "-b", "0.0.0.0"]docker-compose.yml
version: '2' services: rails: build: . image: my/rails5.2 container_name: 'rails' ports: - "80:3000" environment: APP_DATABASE: 'example' APP_DATABASE_USER: 'app' APP_DATABASE_PASSWORD: 'development' volumes: - rails-data:/usr/local/src depends_on: - mysql mysql: image: mysql:5.7 container_name: 'mysql' environment: MYSQL_ROOT_PASSWORD: 'mysql' MYSQL_DATABASE: 'example' MYSQL_USER: 'app' MYSQL_PASSWORD: 'development' ports: - '3306:3306' volumes: - mysql-data:/var/lib/mysql volumes: rails-data: driver_opts: type: none device: [プロジェクトパス]/src o: bind mysql-data: driver: local原因調査
docker-compose logs でログを確認
/usr/local/bundle/gems/bundler-2.0.2/lib/bundler/spec_set.rb:87:in `block in materialize': Could not find i18n-1.7.0 in any of the sources (Bundler::GemNotFound)等
gemが見つからないというエラー。
docker-compose buildを毎回やってgemインストールかけるのが面倒なので下記。対策
gemのインストール先をvolumeに追加
version: '2' services: rails: build: . image: my/rails5.2 container_name: 'rails' ports: - "80:3000" environment: APP_DATABASE: 'example' APP_DATABASE_USER: 'app' APP_DATABASE_PASSWORD: 'development' volumes: - rails-data:/usr/local/src - gem-data:/usr/local/bundle depends_on: - mysql mysql: image: mysql:5.7 container_name: 'mysql' environment: MYSQL_ROOT_PASSWORD: 'mysql' MYSQL_DATABASE: 'example' MYSQL_USER: 'app' MYSQL_PASSWORD: 'development' ports: - '3306:3306' volumes: - mysql-data:/var/lib/mysql volumes: rails-data: driver_opts: type: none device: [プロジェクトパス]/src o: bind mysql-data: driver: local gem-data: driver: local参考
・Docker Compose + Railsでイメージ内でbundle installしているはずなのにgemが無いとエラーがでる。
https://qiita.com/hokita222/items/49f4ca54835e08fdd6b2・【Docker】Railsでgemをcacheする
https://qiita.com/k-penguin-sato/items/d51f09376f5733bb3ae9
- 投稿日:2020-01-13T15:09:37+09:00
【Rails】Rails g model書き方
- 投稿日:2020-01-13T15:05:06+09:00
#Rails + #rspec / ActiveJob perform_later or deliver_later and send email difficult to test. how to resolve it?
- 投稿日:2020-01-13T14:55:39+09:00
[Rails] rails db:migrate してもなぜかmigrationファイルが無視されるとき
Versions
$ ruby -v ruby 2.5.5p157 (2019-03-15 revision 67260) [x86_64-linux] $ bundle exec rails -v Rails 5.1.7症状
こういうことが起きた。
まず、migrateされていないmigrationファイルがあることを確認。
$ bin/rails db:migrate:status database: database名 Status Migration ID Migration Name -------------------------------------------------- up 20200101xxxxxx Devise create users ...(略) down 20200101xxxxxx Drop usersOK!(`・ω・´)
では、migrateしよう。
$ bin/rails db:migrate Model files unchanged.
ほう....何も変わらないと。
では、再度migrate状況を確認しよう。$ bin/rails db:migrate:status database: database名 Status Migration ID Migration Name -------------------------------------------------- up 20200101xxxxxx Devise create users ...(略) down 20200102xxxxxx Drop users終わってないじゃないかあぁぁああorz
対策
db:migrate:redo
で解決した$ bin/rails db:migrate:redo VERSION=20200102xxxxxx Model files unchanged. == 20191125071019 DropUsers: migrating ============================ -- drop_table(:users) -> 0.0046s == 20191125071019 DropUsers: migrated (0.0047s) ===================なんなんじゃい!!(´;ω;`)
発生状況
同じmigrationファイルに対してrollbackとmigrateを何度も繰り返していたら発生した。
migrationファイル自体の書き換えも発生していた。
もしかしたら、railsで管理している内部データか何かが壊れたのではないかと疑っている。面倒なので詳細は掲載しませんが、migrate:statusの結果とDB本体(mysql)のテーブルの状況は正しく一致していました。
- 投稿日:2020-01-13T14:14:31+09:00
Tinder風UIで「好き」「嫌い」を投票できる画面を実装する【React×Railsアプリ開発 第8回】
やったこと
- Tinder風UIで投稿に対して、「好き」か「嫌い」が投票できる画面を実装した。
- モジュールはreact-swipe-card-chsstmを使って、Tinder風UIを実装した。
今回の成果
できなかったこと
- react-swipe-card-chsstmのコードを修正して使いたかったが、どうしても修正後のモジュールが上手くインストールできなかった。
- Github上でForkしたあとにコード修正、再度インストールまでは良かったが、トランスパイル(コンパイル)が上手くいってないのかな〜...
実装手順
モジュールインストール
ここでインストール済み。npm install react-swipe-card-chsstmで行けるはず。
Rails APIの調整
- ログイン中ユーザーが「好き」か「嫌い」か投票していないポストをランダムで10個ずつ返すAPIを作る
- answered_posts_idでログイン中ユーザーが「投票した」ポストのidを呼び出している。
- Posw.where.not("id ~ で、投票したpost_id以外のidのポストの中から10個をランダムで返している。
users_controllerにnot_answered_postsを追加する
users_controller.rbdef not_answered_posts @user = current_api_v1_user answered_posts_id = "SELECT post_id from likes WHERE user_id = :user_id" not_answered_posts = Post.where.not("id IN (#{answered_posts_id})", user_id: @user.id).order('RANDOM()').limit(10) json_data = { 'posts': not_answered_posts, } render json: { status: 'SUCCESS', message: 'Loaded the not_answered_posts', data: json_data} endReact
Home.js
- CardのonSwipeLeft、onSwipeRightで左右にスワイプ時に呼び出す関数を指定している。
- submitSuki, submitKiraiで「好き」「嫌い」を投票している。
- 再読み込みボタンで、リロードし、追加のnot_answered_posts(ログイン中のユーザーがまだ投票していないポスト)を10個ずつ読み込んでいる。
Home.jsimport React from 'react'; import PropTypes from 'prop-types'; import './HomeStyles.css' import { withStyles } from '@material-ui/core/styles'; import Button from '@material-ui/core/Button'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import * as actions from '../actions'; import queryString from 'query-string'; import _ from 'lodash'; import axios from 'axios'; import Cards, { Card } from 'react-swipe-card-chsstm'; import "normalize.css"; //import "./styles.css"; const styles = theme => ({ // ヘッダーロゴ homeimg: { height: '20%', width: '60%', display: 'block', margin: 'auto', }, conceptimg: { display: 'flex', width: '80%', display: 'block', margin: '10px auto', }, button: { margin: '0px 5px', }, sukibutton: { margin: '0px 15px', backgroudColor: '#000000', }, kiraibutton: { margin: '0px 15px', backgroudColor: '#ffffff' }, }); class Home extends React.Component { constructor(props) { super(props) this.state = { not_answered_posts: [] } const auth_token = localStorage.auth_token const client_id = localStorage.client_id const uid = localStorage.uid //新着順 axios.get(process.env.REACT_APP_API_URL + `/api/v1/not_answered_posts`, { headers: { 'access-token': auth_token, 'client': client_id, 'uid': uid } }) .then((response) => { const data = response.data.data; this.setState({ not_answered_posts: data.posts, }); }) .catch(() => { }); this.reload = this.reload.bind(this); } reset = () => { this.setState(state => ({ id: state.id + 1 })); }; componentDidMount() { } submitSuki(post) { const { CurrentUserReducer } = this.props const auth_token = localStorage.auth_token const client_id = localStorage.client_id const uid = localStorage.uid const data = { user_id: CurrentUserReducer.items.id, post_id: post.id, suki: 1, } axios.post(process.env.REACT_APP_API_URL + '/api/v1/likes', data, { headers: { 'access-token': auth_token, 'client': client_id, 'uid': uid } }) .then(response => { }) .catch(error => { }) } submitKirai(post) { const { CurrentUserReducer } = this.props const auth_token = localStorage.auth_token const client_id = localStorage.client_id const uid = localStorage.uid const data = { user_id: CurrentUserReducer.items.id, post_id: post.id, suki: 0, } axios.post(process.env.REACT_APP_API_URL + '/api/v1/likes', data, { headers: { 'access-token': auth_token, 'client': client_id, 'uid': uid } }) .then(response => { }) .catch(error => { }) } reload() { const auth_token = localStorage.auth_token const client_id = localStorage.client_id const uid = localStorage.uid //新着順 axios.get(process.env.REACT_APP_API_URL + `/api/v1/not_answered_posts`, { headers: { 'access-token': auth_token, 'client': client_id, 'uid': uid } }) .then((response) => { const data = response.data.data; var not_answered_posts = this.state.not_answered_posts data.posts.forEach(post => { not_answered_posts.push(post); }); this.setState({ not_answered_posts: not_answered_posts, }); }) .catch(() => { }); } render() { const { CurrentUserReducer } = this.props; const { classes } = this.props; let cards; return ( <div className="home"> <div className="background"> <Button size="large" variant="contained" color="blue" onClick={this.reload} className={classes.sukibutton}> 再読み込み </Button> </div> <Cards onEnd={this.endSwipe} className="master-root" likeOverlay={<h1>スキ</h1>} dislikeOverlay={<h1>キライ</h1>} ref={(ref) => cards = ref} > {this.state.not_answered_posts.map(item => <Card onSwipeLeft={() => this.submitKirai(item)} onSwipeRight={() => this.submitSuki(item)}> <h2>{item.content}</h2> </Card> )} </Cards> <div className="buttonArea"> <Button size="large" variant="contained" color="blue" onClick={() => { cards.dislike(); }} className={classes.sukibutton}> キライ </Button> <Button size="large" variant="contained" color="red" onClick={() => { cards.like(); }} className={classes.kiraibutton}> スキ </Button> </div> </div > ) } } Home.propTypes = { classes: PropTypes.object.isRequired, post: PropTypes.object.isRequired, }; const mapState = (state, ownProps) => ({ CurrentUserReducer: state.CurrentUserReducer, }); function mapDispatch(dispatch) { return { actions: bindActionCreators(actions, dispatch), }; } export default connect(mapState, mapDispatch)( withStyles(styles, { withTheme: true })(Home) );HomeStyles.css
- Home.jsのスタイルを別途HomeStyles.cssに記述しています。
- これ、結構うまくやらないとちゃんと配置してくれなくて苦労しました。Cardを10枚ずつ重ねて表示していて、z-indexを使っているから、ここで配置がずれたりする。
- ここを参考にしました。https://github.com/chsstm/react-swipe-card/blob/master/stories/style.css
HomeStyles.csshtml { -webkit-touch-callout: none; -webkit-user-select: none; -khtml-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } body { margin: 0 auto; padding: 0; font-family: sans-serif; text-align: center; } img { width: 100%; } li { list-style: none; } h2 { text-align: left; word-wrap: break-word; margin: 20px; font-size: 20pt; } .home { position: relative; overflow: hidden; width: 100%; height: 100%; min-height: 500px; } .master-root { margin: 10px 0px; position: absolute; height: 50%; width: 100%; /* z-index:2; */ } .card { background-color: white; background-size: cover; position: absolute; left: 0; right:0; top:0; bottom:0; background: #f8f3f3; height: 80%; width: 80%; margin: auto; transition: box-shadow 0.3s; box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23); border: 10px solid #212121; cursor: pointer; } .buttonArea{ position: absolute; bottom: 20%; width: 100%; margin: 0 auto; } .animate { transition: transform 0.3s; box-shadow: none; } .inactive { box-shadow: none; } .alert { width: 45%; min-height: 10%; position: absolute; z-index: 9999; opacity: 0; transition: opacity 0.5s; color: white; vertical-align: middle; line-height: 3rem; } .alert-visible { opacity: 1; } .alert-right { top: 0; right: 0; background: red; border-top-left-radius: 50px; border-bottom-left-radius: 50px; } .alert-left { top: 0; left: 0; background: blue; border-top-right-radius: 50px; border-bottom-right-radius: 50px; } .alert-top { background: black; border-radius: 50px; transform: translate(-50%, 0); margin-left: 50%; } .alert-bottom { bottom: 0; background: black; border-top-left-radius: 50px; border-radius: 50px; transform: translate(-50%, 0); margin-left: 50%; } .action-button { cursor: pointer; padding: 10px 50px; border: none; outline: none; background-color: lightgrey; margin: 0px 10px; } .action-button:active { background-color: black; color: white; }
- 投稿日:2020-01-13T13:29:04+09:00
そのまま書くには違和感のあるロジックはクラスに分離しよう〜毎週○曜日に実行されるRakeタスクの実装を例に(Heroku)
問題
Herokuでスケジュール実行のために用意されているアドオンHeroku Schedulerでは「10分毎」「1時間毎」「1日毎」のオプションが用意されているものの、「1週間毎」に実行したいときのオプションはないので、週次でタスクを実行したい場合はRails側で実装する必要があります。さて、それでは週次で実行するための判定処理はどこに書くべきでしょうか?
よくあるコード
こんなときよく見かけるのがパターンAとパターンBのコードですが、双方ともデメリットが大きいです。
パターンA: Rakeタスク側に判定コードを書く
DAY_OF_WEEK = %i[sun mon tue wed thu fri sat].freeze namespace :awesome_check do desc 'Invoke AwesomeChecker' task run: :environment do if DAY_OF_WEEK[Date.current.wday] == :sat AwesomeChecker.call end end endシンプルではありますが、Rakeタスクのテストは書きづらいので、このコードの例で言えば本当に土曜日だけに実行されるように担保したい場合に困ります。
【参考】Rakeタスクのテストを書きたい人はこちらの記事が参考になります
RailsでRakeタスクをシンプルかつ効果的にテストする手法パターンB: AwesomeChecker側に判定コードを書く
class AwesomeChecker # ...(中略)... def call return false unless DAY_OF_WEEK[Date.current.wday] == :sat # (処理の中身を書く) end # ...(中略)... end「週次実行するビジネスロジック」と「AwesomeCheckerのそもそものビジネスロジック」は目的が異なるので、コードとしていびつです。そもそも、所定の曜日にしか実行できない関数が存在するのは不気味ですよね。
解決策: 週次実行を管理するクラスを作る
どちらにロジックを書いても不自然になるのであれば、新しくクラスを作ってロジックを分離するのが策になります。Rakeタスクにこんな感じのコードを書けるとすれば、Rakeタスクにロジックを書かなくても済むし、AwesomeChecker上に週次実行のロジックを盛り込まなくても済みそうです。
namespace :awesome_check do desc 'Invoke AwesomeChecker' task run: :environment do # NOTE: # Procで実行したいコードを渡しておけば、 # 即座に実行されてしまうことはありません DayOfWeekInvoker.call(:sat, -> { AwesomeChecker.call }) end endこのような「指定された曜日にだけ第二引数の関数が実行される」クラスの実装はこんな感じになるかと思います。
class DayOfWeekInvoker DAY_OF_WEEK = %i[sun mon tue wed thu fri sat].freeze def self.call(*args) new(*args).call end def initialize(day_of_week, func) @day_of_week = day_of_week @func = func unless valid_day_of_week? raise ArgumentError, "無効な引数です: #{day_of_week}" end end def call unless DAY_OF_WEEK[Date.current.wday] == @day_of_week Rails.logger.info '[DayOfWeekInvoker] Invocation Skipped' return end @func.call end private def valid_day_of_week? @day_of_week.in? DAY_OF_WEEK end endHeroku Schedulerのオプションで「日毎」が選択できるため、1日1回だけ実行されるというロジックはHeroku Schedulerにお任せしている実装にはなりますが、1日1回だけ実行されることをRails側で担保したい場合もこのような発想でクラスを分離すればテストも簡単になります。
「何だかロジックが入り組んでテストを書きづらくなったなー」というときにご参考ください。
- 投稿日:2020-01-13T11:18:00+09:00
#Rails / has_one and has_many association both / without DB foreign key
- 投稿日:2020-01-13T11:17:57+09:00
#Rails で DBに外部キー制約がない場合 has_one と has_many のアソシエーションを両方指定できそうだ
- 投稿日:2020-01-13T11:02:17+09:00
#Rails が自動付与した fk_rails_... という名前の外部キーをマイグレーションで削除する
直接指定出来せるっぽい
もっと書き方やり方がありそうだけど
class RemoveUniqueIndexFromSomeTable < ActiveRecord::Migration[5.2] def change remove_foreign_key :some_tables, name: "fk_rails_43240587ab" end enddb:migrate == 20200112003437 RemoveUniqueIndexFromSomeTable: migrating =============== -- remove_foreign_key(:some_tables, {:name=>"fk_rails_43240587ab"}) -> 0.0250s ...Original by Github issue
- 投稿日:2020-01-13T11:02:13+09:00
#Rails remove auto generated name fk_rails_... foreign key migration
it is the wrong way?
class RemoveUniqueIndexFromSomeTable < ActiveRecord::Migration[5.2] def change remove_foreign_key :some_tables, name: "fk_rails_43240587ab" end enddb:migrate == 20200112003437 RemoveUniqueIndexFromSomeTable: migrating =============== -- remove_foreign_key(:some_tables, {:name=>"fk_rails_43240587ab"}) -> 0.0250s ...Original by Github issue
- 投稿日:2020-01-13T10:16:39+09:00
Ruby on Rails APIモードでいいね機能を実装する【初学者のReact×Railsアプリ開発 第6回】
やったこと
- ログイン中のユーザーが、ポスト(投稿)に対して、「好き」「嫌い」「興味ない」を投票でき、更新もできるようにした。
- likesテーブルをpostsテーブル、usersテーブルとリレーションさせた。
- counter_cultureを使って、関連レコードの集計(postsテーブルのsuki_countなど)を行った
- PostgreSQL 12の新機能であるGenerated Columnを使用して、関連レコードの集計結果の計算(postsテーブルのall_count, suki_percent)を行わせた。
完成したデータベースのイメージ(dbconsoleを使って確認)
app_development=# select * from likes; id | user_id | post_id | suki | created_at | updated_at ----+---------+---------+------+----------------------------+---------------------------- 1 | 1 | 2 | 1 | 2020-01-12 23:54:28.193197 | 2020-01-12 23:54:28.193197 (1 row) app_development=# select * from posts; id | content | user_id | created_at | updated_at | suki_count | kirai_count | notinterested_count | all_count | suki_percent ----+---------+---------+--------------------------+--------------------------+------------+-------------+---------------------+-----------+-------------- 2 | bbbbd | 1 | 2020-01-12 12:41:03.4942 | 2020-01-12 12:41:03.4942 | 1 | 0 | 0 | 1 | 100実装手順
counter_cultureのインストール
counter_cultureは、各ポストに対して何件の「好き」「嫌い」が投票されたのか、など関連レコード数の集計に使うモジュールです。
Gemfilegem 'counter_culture'gemfileにcounter_cultureを追加。
$ docker-compose build --no-cacheモデルとコントローラーの作成
$ docker-compose run api rails g model like suki:integer $ docker-compose run api rails g controller api/v1/likesXXX_create_likes.rbclass CreateLikes < ActiveRecord::Migration[5.2] def change create_table :likes do |t| t.integer :user_id, null: false t.integer :post_id, null: false t.integer :suki, null: false t.timestamps t.index :user_id t.index :post_id t.index [:user_id, :post_id], unique: true end end end
- suki:0ならば「嫌い」、suki:1ならば「好き」、suki:2なら「興味なし」とする。
- user_idとpost_idの組み合わせがユニークであることを書く。重複データを避けるため。
追加のマイグレーションファイルの作成
- 関連レコードの集計のためのカラムやそれをパーセント表記するための列を追加します。(あるポストに対して「好き」が何票か、「嫌い」が何票か、「好き」と「嫌い」の合計は何票か、「好き」は何%か)
$ docker-compose run api rails g migration AddLikesCountToPosts $ docker-compose run api rails g migration AddAllCountToPosts $ docker-compose run api rails g migration AddSukipercentToPostsXXX_add_likes_count_to_posts.rbclass AddLikesCountToPosts < ActiveRecord::Migration[5.2] class MigrationUser < ApplicationRecord self.table_name = :posts end def up _up rescue => e _down raise e end def down _down end private def _up MigrationUser.reset_column_information add_column :posts, :suki_count, :integer, null: false, default: 0 unless column_exists? :posts, :suki_count add_column :posts, :kirai_count, :integer, null: false, default: 0 unless column_exists? :posts, :kirai_count add_column :posts, :notinterested_count, :integer, null: false, default: 0 unless column_exists? :posts, :notinterested_count end def _down MigrationUser.reset_column_information remove_column :posts, :suki_count if column_exists? :posts, :suki_count remove_column :posts, :kirai_count if column_exists? :posts, :kirai_count remove_column :posts, :notinterested_count if column_exists? :posts, :notinterested_count end end
- suki_count, kirai_count, nointerested_countというカラムをpostsテーブルに追加しています。counter_cultureを使って、likesテーブルに「好き」「嫌い」「興味なし」が投票されたときに、+1され、likesテーブルのデータが更新されたら-1、+1がされます。
XXX_add_all_count_to_posts.rbclass AddAllCountToPosts < ActiveRecord::Migration[5.2] def up execute "ALTER TABLE posts ADD COLUMN all_count real GENERATED ALWAYS AS (suki_count+kirai_count) STORED;" add_index :posts, :all_count, unique: false end def down remove_column :posts, :all_count end end
- postsテーブルにall_countカラムを追加。
- PostgreSQL 12の新機能「Generated Column」を使って、suki_countとkirai_countの合計をall_countに自動計算させるように記述しています。
Generated Columnとは
“generated column” を使うと、”(同じテーブル内の) 他の列の値を利用した計算結果” を、特定の列に格納することが可能になります。https://tech-lab.sios.jp/archives/17098
XXX_add_sukipercent_to_posts.rbclass AddSukipercentToPosts < ActiveRecord::Migration[5.2] def up execute "ALTER TABLE posts ADD COLUMN suki_percent real GENERATED ALWAYS AS ( CASE WHEN (suki_count+kirai_count) = 0 THEN NULL ELSE suki_count*100/(suki_count+kirai_count) END ) STORED;" add_index :posts, :suki_percent, unique: false end def down remove_column :posts, :suki_percent end end
- suki_percentカラムをpostsテーブルに追加。
- ここでもgenerated columnを使って、「好き」の票数の「好き」と「嫌い」の合計に対するパーセンテージを求めてsuki_percentに格納するように記述しています。
モデルの編集
user.rbclass User < ActiveRecord::Base has_many :posts, dependent: :destroy has_many :likes, dependent: :destroy # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable, :omniauthable, omniauth_providers: [:twitter] include DeviseTokenAuth::Concerns::User end
- テーブル間のリレーションシップの追加
post.rbclass Post < ApplicationRecord belongs_to :user has_many :likes, dependent: :destroy end
- テーブル間のリレーションシップの追加
like.rbclass Like < ApplicationRecord belongs_to :user belongs_to :post validates :user_id, presence: true validates :post_id, presence: true counter_culture :post, column_name: -> (model) {"#{model.like_type_name}_count"} def like_type_name if suki == 1 then return 'suki' elsif suki == 0 then return 'kirai' elsif suki == 2 then return 'notinterested' end end end
- counter_cultureの実装はここで行っている。
- likesテーブルにデータがcreateされたとき「好き(suki==1)」ならばsuki_countを+1するようにしている...のような処理を記述している。
likesコントローラーの編集
likes_controllermodule Api module V1 class LikesController < ApplicationController before_action :authenticate_api_v1_user! before_action :set_like, only: [:show, :destroy, :update,] def index likes = Like.order(created_at: :desc) render json: { status: 'SUCCESS', message: 'Loaded posts', data: likes } end def show if @like.nil? then data = { updated_at: 3, suki: 3 } render json: { status: 'SUCCESS', message: 'Loaded the like', data: data } else render json: { status: 'SUCCESS', message: 'Loaded the like', data: @like } end end def create like = Like.new(like_params) if like.save @post = Post.find(params[:post_id]) @user = @post.user @like = Like.find_by(user_id: @user.id, post_id: params[:post_id]) json_data = { 'post': @post, 'user': { 'name': @user.name, 'nickname': @user.nickname, 'image': @user.image }, 'like': @like } render json: { status: 'SUCCESS', data: json_data} else render json: { status: 'ERROR', data: like.errors } end end def destroy @like.destroy render json: { status: 'SUCCESS', message: 'Delete the post', data: @like} end def update data = { 'user_id': @user.id, 'post_id': params[:post_id], 'suki': params[:suki] } if @like.update(data) @post = Post.find(params[:post_id]) @user = @post.user json_data = { 'post': @post, 'user': { 'name': @user.name, 'nickname': @user.nickname, 'image': @user.image }, 'like': @like } render json: { status: 'SUCCESS', message: 'Updated the post', data: json_data } else render json: { status: 'SUCCESS', message: 'Not updated', data: @like.errors } end end private def set_like @user = User.find_by(id: current_api_v1_user.id) @like = Like.find_by(user_id: @user.id, post_id: params[:post_id]) end def like_params params.require(:like).permit(:post_id, :user_id, :suki) end end end end
- 一般的なCRUD用のAPIの記述かな...
- showでは、該当するlkeのデータが無かったときはエラーになるのを避けるために「suki: 3」として返している。まだ投票していないのかアクセスエラーなのかを区別するために...
Postmanを使ってテスト
- likeをPOSTしたときに、suki_countが+1され、自動的にsuki_percent, all_countが計算されているのがわかる。
- 投稿日:2020-01-13T08:22:16+09:00
#Ruby or #Rails で unixtimestamp の数値を Time.zone の日時に変換する
In Ruby need require
require 'active_support/core_ext'Time
Time.at(1580655600) # => 2020-02-02 15:00:00 +0000Timezone UTC
Time.use_zone('UTC') { Time.zone.at(1580655600) } # => Sun, 02 Feb 2020 15:00:00 UTC +00:00Timezone JST
Time.use_zone('Tokyo') { Time.zone.at(1580655600) } # => Mon, 03 Feb 2020 00:00:00 JST +09:00Original by Github issue