20200113のRailsに関する記事は20件です。

【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引数が見つかりませんとエラーを吐いてしまっていたんですね:sweat_smile:

おわりに

最後まで読んで頂きありがとうございました:bow_tone1:

どなたかの参考になれば幸いです:relaxed:

参考にさせて頂いたサイト(いつもありがとうございます)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

個人開発 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 を利用することにしました。

方法: どうやって移行したか

移行の基本戦略は次のとおりです。
ここでいう認証情報は、メールアドレスとパスワードを指します。

  1. 認証情報の書き込み(招待・メールアドレス/パスワード変更)を止める
  2. 認証情報を Auth0 にインポートする
  3. 認証情報の読み込み(ログイン・メール送信)を Auth0 に切り替える
  4. 認証情報の書き込みを Auth0 に置き換えて再開する

移行中にデータの変更を止められるのは、身内向けの小規模アプリだからこそですね。
丁寧にやるなら、移行状態に応じてうまくハンドリングしながら並行して書き込みを行うような工夫が必要だと思います。
ただ、それは手間がかかるので、今回はサービスの機能を一部停止して一気にガッと切り替える方針を取ることにしました。

以下、それぞれについて詳しく説明します。

認証情報の書き込みを止める

書き込みを止めるのは、データのインポート後 Auth0 への切り替え前に更新が行われ、Auth0 のデータが古いままになるという事態を避けるためです。
また、移行中に何かあって切り戻す際にも、データの変更がないとわかっていれば安心して切り戻せます。

認証情報を Auth0 にインポートする

Auth0 へのデータ移行には主に次の2つの方法があります。

今回のケースでは、まず 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 を用いるものがあります。

前者は、パスワードを忘れた場合等に 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

manage_auth0_com_dashboard_us_patient-bar-7812__iPad_Pro_.png

無料プランでは2日分だけですが、ログイン成功・失敗やパスワード変更のログも保存されています。
移行直後は定期的にログ一覧をみて、ログインできないまま諦めているユーザーがいないか監視していました。

個人開発ではなかなかログやダッシュボードに手が回らないので、こうしたデータが見られるのは嬉しいですね。

開発環境の複雑化

仕方ないことではあるのですが、これがデメリットでしょうか。
開発環境用の Tenant を作成し、環境変数を設定しないとローカルで認証周りがまともに動きません。
一度設定してしまえば大したことはないのですが、最初の環境構築のハードルは上がりますね。

おわりに

以上、LiveLog における認証周りの Auth0 移行について紹介しました。

  • 個人開発の小規模 Ruby on Rails アプリケーション (not SPA)
  • ユーザー登録は招待制
  • ログイン方法はメールアドレス+パスワードのみ
  • 一部機能停止を伴う移行方法

という特殊な事例でしたが、参考になれば幸いです。


  1. Custom Domains が利用できないのは手痛いですが、メリットの方が大きいので妥協しました。 

  2. ユニバーサルログインの使用でログイン画面のドメインが ***.auth0.com になり、ユーザー側でブラウザのパスワード自動入力が効かなくなっていたため、少なくないユーザーがログインに失敗してパスワード変更していました。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

railsのデータベースをmysqlにする

rails アプリ作成時

rails new アプリケーション名 -d mysql

データベース設定ファイル config/database.yml

database.yml
default: &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
でアクセスするとテストページに飛ぶ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【実践】SolidusでRails製ECサイトを作ってみる

SolidusはSpreeの後継のRails製オープンソースECサイトのプロジェクト。
OSSを使わないとしても、ECに最低限の機能が入ってるので、自分たちで機能の洗い出しするより、これを参考にした方が早い。

準備

以下をインストールしていない人はインストール

Rubyはバージョンが低いとfiniteのエラーが出ます。
テスト環境ではv2.5.1を使用しました。

brew install sqlite3 imagemagick

1. Railsアプリを作成

rails new myshop --skip_webpack_install
cd myshop

2. Gemfileに以下を追加

Gemfile
gem '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:migrate

spreeは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にアクセスすると、管理画面が表示されます。

スクリーンショット 2020-01-13 16.41.11.png

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": []
                    }
                }
            ]
        },
...

まとめ

詳しくはもう少しいじって追記していく予定。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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
end
class User < ApplicationRecord
  has_one :address, dependent: :destroy
end
class Address < ApplicationRecord
  belongs_to :user
end

要約

要約すると、has_one throughhas_oneはアソシエーション先のhas_one :addressのことであり、throughは自モデル内に書かれているhas_one :userのことである

出力結果

@booking = Booking.find_by(id: 1)
@booking.address.zip_code
# => 150-0002
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

トランザクションのネストの使い方まとめた(初心者向け)

トランザクションのネストについてまとめてみました
どう記述したらネストができるの?
ロールバックした時の挙動は?
などなどまとめてみました
自分がよく使う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')
end
BEGIN
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')
end
BEGIN
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 # <===ココ
end
BEGIN
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')
end
BEGIN
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')
end
BEGIN
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 # <=== ココ
end
BEGIN
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')
end
BEGIN
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 メソッドは実際には BEGINSAVEPOINT クエリを発行していないため、対応する 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 を投げよう
      • それ以外の例外だと外側も一緒にロールバックしちゃうぞ

まとめのまとめ

可能な限り、トランザクションのネストってしない方がいいよ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

初心者向け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で行うというのが多かったです。今回もこの役割分担でアプリを作っていきます。

スクリーンショット 2020-01-01 3.16.36.png

WEB業界での経験が浅い、もしくはこれからWEB業界を目指す方はAPIのイメージがつかみにくいかもしれませんが、一言で言うとURLのリクエストを受けたら、URLに応じたデータを返すものです。

この役割のイメージが分かればRailsの部分をFirebaseに置き換えようとか、Vue.jsをReactに置き換えようとか応用が効くような気がします。

(注)私は現時点でFirebaseもReactも詳しくないので応用が効く気がするというだけ。。。詳しい方がいたら教えて下さると助かります。

完成後のイメージ

スクリーンショット 2020-01-13 14.31.32.png

  • テキストボックスにタスクを入力して追加ボタンを押すとリストに追加されて表示される。
  • チェックボックスをチェックすると取り消し線が引かれる
  • 削除ボタンを押すと削除

実際に作ってみよう

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.rb
class HomeController < ApplicationController
  def index
  end
end

viewからVue.jsが呼び出せるか試しますために追加します。

/app/views/home/index.erb
<%= javascript_pack_tag 'hello_vue' %>

routes.rbに以下を追加。

/config/routes.rb
Rails.application.routes.draw do
  root to: 'home#index'
end

rails sしてlocalhost:3000にアクセスします。
以下のような画面が表示されてればOKです。

スクリーンショット 2020-01-02 23.00.33.png

ちなみにVue.jsを変更したら

bin/webpackで更新してあげる必要があります。(重要)

APIの処理を作る

まずは、ToDoリストにタスクを追加するためにモデルを作っていきます。

rails generate model Task name:string is_done:boolean

ルーティングに以下を追加します。
表示用のhomeとデータを返すAPI用のapi::tasksを追加。

/config/routes.rb
Rails.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.rb
module 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
end

APIを返す時は、htmlではなくJSONで返してあげたいので以下を追加します。
自分は実際にWEB業界に入るまでJSONに馴染みがなかったのですが、以下の形で書きます。

/app/views/api/tasks/index.json.jbuilder
json.set! :tasks do
  json.array! @tasks do |task|
    json.extract! task, :id, :name, :is_done, :created_at, :updated_at
  end
end

APIの動作確認

DBにデータを入れて確認してみましょう。
コンソールを立ち上げます。

rails c

Taskモデルにデータを追加してみましょう。

Task.create(name: 'テスト用タスク')

もう一度サーバー立ち上げ

rails s

以下のアドレスで追加したデータがJSONでデータが返ればOK。

http://localhost:3000/api/tasks.json

スクリーンショット 2020-01-02 23.40.55.png

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.js
import 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.js
module.exports = {
  test: /\.styl$/,
  use: [
    'style-loader', 'css-loader', 'stylus-loader'
  ]
}
/config/webpack/environment.js
const { 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/webpack
rails s

rails sしてヘッダーが表示されて入ればOKです

スクリーンショット 2020-01-13 14.10.00.png

ToDoリストを表示するボディ部分

axiosというライブラリを使って、フロントエンドからHTTPリクエストをします。
以下のコマンドでyarnでaxiosを追加して下さい。

yarn add axios

ToDoアプリのメイン部分の実装です。解説は後ほど詳しく説明します。

/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.js
import 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リストを追加してみましょう。

スクリーンショット 2020-01-13 16.31.25.png

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でアプリを作りたい人向けに記事を改訂して行きたいです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rails】Rails g model書き方

Rails g model書き方の備忘録です。

書き方

ターミナル
rails g model モデル名 カラム:データ型

モデル名

  • 大文字で始める
  • 単数形

カラム:データ型

半角スペースで連続で記載できる

ターミナル
rails g model Post content:text image:string

データ型

string:255文字までの文字列
text:255文字以上の文字列
integer:整数

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

#Rails + #rspec / ActiveJob perform_later or deliver_later and send email difficult to test. how to resolve it?

change later to now and test it!

it { expect { perform_enqueued_jobs { subject } }.to_not raise_error }

Original by Github issue

https://github.com/YumaInaura/YumaInaura/issues/2942

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[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 users

OK!(`・ω・´)

では、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)のテーブルの状況は正しく一致していました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Tinder風UIで「好き」「嫌い」を投票できる画面を実装する【React×Railsアプリ開発 第8回】

やったこと

  • Tinder風UIで投稿に対して、「好き」か「嫌い」が投票できる画面を実装した。
  • モジュールはreact-swipe-card-chsstmを使って、Tinder風UIを実装した。

今回の成果

v1n1g-k17jj.gif

できなかったこと

  • 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.rb
      def 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}
      end 

React

Home.js

  • CardのonSwipeLeft、onSwipeRightで左右にスワイプ時に呼び出す関数を指定している。
  • submitSuki, submitKiraiで「好き」「嫌い」を投票している。
  • 再読み込みボタンで、リロードし、追加のnot_answered_posts(ログイン中のユーザーがまだ投票していないポスト)を10個ずつ読み込んでいる。
Home.js
import 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.css
html {
  -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;
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

そのまま書くには違和感のあるロジックはクラスに分離しよう〜毎週○曜日に実行される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
end

Heroku Schedulerのオプションで「日毎」が選択できるため、1日1回だけ実行されるというロジックはHeroku Schedulerにお任せしている実装にはなりますが、1日1回だけ実行されることをRails側で担保したい場合もこのような発想でクラスを分離すればテストも簡単になります。

「何だかロジックが入り組んでテストを書きづらくなったなー」というときにご参考ください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

#Rails / has_one and has_many association both / without DB foreign key

You can specify both

class User < ApplicationRecord
  has_one :book
  has_many :books
end

If use has one association and user has many books

user.book

then it maybe returns newest one "book" instance
but it does not return older other instances

Original by Github issue

https://github.com/YumaInaura/YumaInaura/issues/2940

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

#Rails で DBに外部キー制約がない場合 has_one と has_many のアソシエーションを両方指定できそうだ

You can specify both

class User < ApplicationRecord
  has_one :book
  has_many :books
end

userが持つbookが複数ある場合にhas_oneを見ると、最新のbook一個だけが得られた

user.books.size # 3

user.book # Return latest book instance a user has

Original by Github issue

https://github.com/YumaInaura/YumaInaura/issues/2941

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

#Rails が自動付与した fk_rails_... という名前の外部キーをマイグレーションで削除する

直接指定出来せるっぽい

もっと書き方やり方がありそうだけど

class RemoveUniqueIndexFromSomeTable < ActiveRecord::Migration[5.2]
  def change
    remove_foreign_key :some_tables, name: "fk_rails_43240587ab"
  end
end
db:migrate

== 20200112003437 RemoveUniqueIndexFromSomeTable: migrating ===============
-- remove_foreign_key(:some_tables, {:name=>"fk_rails_43240587ab"})
   -> 0.0250s
...

Original by Github issue

https://github.com/YumaInaura/YumaInaura/issues/2939

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

#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
end
db:migrate

== 20200112003437 RemoveUniqueIndexFromSomeTable: migrating ===============
-- remove_foreign_key(:some_tables, {:name=>"fk_rails_43240587ab"})
   -> 0.0250s
...

Original by Github issue

https://github.com/YumaInaura/YumaInaura/issues/2938

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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は、各ポストに対して何件の「好き」「嫌い」が投票されたのか、など関連レコード数の集計に使うモジュールです。

Gemfile
gem '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/likes
XXX_create_likes.rb
class 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 AddSukipercentToPosts
XXX_add_likes_count_to_posts.rb
class 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.rb
class 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.rb
class 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.rb
class 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.rb
class Post < ApplicationRecord
  belongs_to :user
  has_many :likes, dependent: :destroy
end
  • テーブル間のリレーションシップの追加
like.rb
class 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_controller
module 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を使ってテスト

スクリーンショット 2020-01-13 10.08.25.png

  • likeをPOSTしたときに、suki_countが+1され、自動的にsuki_percent, all_countが計算されているのがわかる。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

#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 +0000

Timezone UTC

Time.use_zone('UTC') { Time.zone.at(1580655600) }
# => Sun, 02 Feb 2020 15:00:00 UTC +00:00

Timezone JST

Time.use_zone('Tokyo') { Time.zone.at(1580655600) }
# => Mon, 03 Feb 2020 00:00:00 JST +09:00

Original by Github issue

https://github.com/YumaInaura/YumaInaura/issues/2937

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails6 rails consoleからカラムのデータ型を確認する

目的

  • rails consoleからのカラムのデータ型の確認方法をまとめる

実施方法

  1. railsアプリ名フォルダ直下で下記コマンドを実行してrails consoleを起動する。

    $ rails console
    
  2. rails console上で下記コマンドを実行してカラムのデータ型を確認する。

    >モデルの名前.columns_hash['カラムの名前'].type
    
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む