20200115のRailsに関する記事は22件です。

Rails modelのjoin戦略についての理解を深める1歩

こちらの記事。よく参考にしてます。
https://qiita.com/k0kubun/items/80c5a5494f53bb88dc58

ここで言っているキャッシュとは何か?
同記事の最後の表についてなんとなくふわっとした理解でした。
特にキャッシュする・しないについて。
それについてまとめました。

Rails modelって

そのmodelに定義されたリレーションに則ってデータを取得できます

class User < ApplicationRecord
  has_many :invoices
  ...

というUserモデルがあったとして

user = User.first
invoice = user.invoices

というふうに取得できますね
その時、invoicesはどのタイミングで取得する(SQLが発行される)かというと

user.invoices

としたタイミングです

結合してみよう

先程のmodelを使って結合してみよう

joins

user = User.joins(:invoices).first
user.invoices

1行目のタイミングで

 SELECT "users".* FROM "users" INNER JOIN "invoices" ON "invoices"."user_id" = "users"."id" ORDER BY "users"."id" ASC LIMIT $1

2行目で

SELECT "invoices".* FROM "invoices" WHERE "invoices"."user_id" = $1 

のようなSQLが発行されました。
invoicesを検索するタイミングは変わってませんね。
なるほど、これがキャッシュされたかどうかってことか。

preload

user = User.preload(:invoices).first
user.invoices

includes

preload/eager_loadをいい感じに判断して使用してくれる

1行目の時点でinvoiceも検索しに行ってます

  User Load (0.3ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1]]
  Invoice Load (0.3ms)  SELECT "invoices".* FROM "invoices" WHERE "invoices"."user_id" = $1  [["user_id", 1]]

2行目では特にSQLは発行されていません。
キャッシュされてますね。
そして、SQLの結合は使わずに2回SELECTすることでuser.invoicesを取得してます。

eager_load

user = User.eager_load(:invoices).first
user.invoices

Usersに対してInvoicesをLEFT OUTER JOINで結合してました。
user.invoicesではキャッシュされてるので何も発行しません。

キャッシュしないということ

joinsメソッドでは、結合先のデータを必要としないが、INNER JOINで絞り込みをしたいときに使用する。
なので、

user = User.joins(:invoices).first
user.invoices

のようなコードは無駄があるということ。
includesとwhereで絞り込みとキャッシュを行うべき。

以上

という感じ

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

ログアウト、アカウント削除の実装 【初学者のReact×Railsアプリ開発 第13回】

やったこと

  • ログアウトとアカウント削除の実装
  • ログアウトでは、ブラウザのLocalStorageに保存してあるtokenなどの情報を消去している。
  • アカウント削除はRailsでdestroy。

成果物

pvd7u-2l44j.gif

実装手順(Rails)

users_controller

  • 消去するアカウントはログイン中のアカウントなので、current_userを使っている
users_controller.rb
      def destroy
        @user = current_api_v1_user
        @user.destroy
        render json: { status: 'SUCCESS', message: 'Delete the user', data: @user}
      end

実装手順(React)

Logout.js

  • ログアウトといっても、LocalStorageの情報を消去するだけ。
  • localStorage.clear();
Logout.js
class Logout extends React.Component {

  constructor(props) {
    super(props);
  }

  Logout() {
    localStorage.clear();
    window.location.href = process.env.REACT_APP_BASE_URL;
  }

  notLogout() {
    window.history.back()
  }

  render() {
    const { classes } = this.props;
    return (
      <div>
        <h3>ログアウトしますか?</h3>
        <Button variant="contained" size="large" color="secondary" className={classes.button} onClick={this.Logout}>
          する
        </Button>
        <Button variant="contained" size="large" color="primary" className={classes.button} onClick={this.notLogout}>
          しない
        </Button>
      </div>
    )

  }
}

DeleteAccount.js

  • HTTPのDELETEメソッドを使って消去している
DeleteAccount.js
class Deleteaccount extends React.Component {

  constructor(props) {
    super(props);
    this.Deleteaccount = this.Deleteaccount.bind(this);
  }

  Deleteaccount() {
    const { CurrentUserReducer } = this.props;
    const auth_token = localStorage.auth_token
    const client_id = localStorage.client_id
    const uid = localStorage.uid
    axios.delete(process.env.REACT_APP_API_URL + `/api/v1/users`,
      {
        headers: {
          'access-token': auth_token,
          'client': client_id,
          'uid': uid
        }
      })
    localStorage.clear();
    window.location.href = process.env.REACT_APP_API_URL;
  }

  notDeleteaccount() {
    window.history.back()
  }

  render() {
    const { classes } = this.props;


    return (
      <div>
        <h3>アカウントを削除しますか?</h3>
        <Button variant="contained" size="large" color="secondary" className={classes.button} onClick={this.Deleteaccount}>
          する
        </Button>
        <Button variant="contained" size="large" color="primary" className={classes.button} onClick={this.notDeleteaccount}>
          しない
        </Button>
      </div>
    )

  }
}

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

GemのソースをTracePointを使って効率的に読む

Rubyには様々な便利なGemがあるので有効活用しない手はないですよね。
ただ使っているGemが予期せぬ挙動をした時やドキュメントに載ってないような詳細仕様を知りたい時などにソースを読みたくなることがあります。

Gemのソースを読みたい場合、GithubなどWeb上に公開されていることが多いのでブラウザでソースを見たり、ローカルにソースを落としてきて見たりすると思います。

ただ愚直にソースを読み始めるとソース量が膨大だったり、メタプロが多用されていたりなどで読解がかなり大変です。
そこでこの記事ではTracePointを使って効率的にソースを読む方法を紹介します。

ソースを読んでみよう

具体例があった方が良いので、今回はrailsのfind_or_create_byを使った場合に呼ばれるソースを探すことにしましょう。
https://github.com/rails/rails

今回はローカルマシンにチェックアウトして読むことにします。
この記事では6-0-stableブランチ(2020/01/14時点)を使っています。

愚直にやってみよう

該当箇所の探し方は人それぞれだと思いますが、私は最初はメソッド名でgrepすることが多いです。
今回はactiverecord配下にあることが明白なのでactiverecord/配下でgit grep find_or_create_byしました。

activerecord % git grep find_or_create_by
CHANGELOG.md:    `ActiveRecord::Base.find_or_create_by`/`!` by leaning on unique constraints in the database.
lib/active_record/associations.rb:      #       def find_or_create_by_name(name)
lib/active_record/associations.rb:      #         find_or_create_by(first_name: first_name, last_name: last_name)
lib/active_record/associations.rb:      #   person = Account.first.people.find_or_create_by_name("David Heinemeier Hansson")
lib/active_record/associations.rb:      #     def find_or_create_by_name(name)
lib/active_record/associations.rb:      #       find_or_create_by(first_name: first_name, last_name: last_name)
lib/active_record/associations.rb:        #     def find_or_create_by_name(name)
lib/active_record/associations.rb:        #       find_or_create_by(first_name: first_name, last_name: last_name)
lib/active_record/associations.rb:        #     def find_or_create_by_name(name)
lib/active_record/associations.rb:        #       find_or_create_by(first_name: first_name, last_name: last_name)
lib/active_record/querying.rb:      :find_or_create_by, :find_or_create_by!, :find_or_initialize_by,
lib/active_record/relation.rb:    #   User.find_or_create_by(first_name: 'Penélope')
lib/active_record/relation.rb:    #   User.find_or_create_by(first_name: 'Penélope')
lib/active_record/relation.rb:    #   User.create_with(last_name: 'Johansson').find_or_create_by(first_name: 'Scarlett')
lib/active_record/relation.rb:    #   User.find_or_create_by(first_name: 'Scarlett') do |user|
lib/active_record/relation.rb:    def find_or_create_by(attributes, &block)
lib/active_record/relation.rb:    # Like #find_or_create_by, but calls
lib/active_record/relation.rb:    def find_or_create_by!(attributes, &block)
lib/active_record/relation.rb:    # This is similar to #find_or_create_by, but avoids the problem of stale reads between the SELECT
lib/active_record/relation.rb:    # * While we avoid the race condition between SELECT -> INSERT from #find_or_create_by,
lib/active_record/relation.rb:    # Like #find_or_create_by, but calls {new}[rdoc-ref:Core#new]
lib/active_record/relation/finder_methods.rb:    #   Person.find_or_create_by(name: 'Spartacus', rating: 4)
test/cases/finder_respond_to_test.rb:    assert_not_respond_to Topic, :fail_to_find_or_create_by_title
test/cases/finder_respond_to_test.rb:    assert_not_respond_to Topic, :find_or_create_by_title?
test/cases/finder_test.rb:    assert_raise(NoMethodError) { Topic.fail_to_find_or_create_by_title("Nonexistent Title") }
test/cases/finder_test.rb:    assert_raise(NoMethodError) { Topic.find_or_create_by_title?("Nonexistent Title") }
test/cases/nested_attributes_test.rb:      ).find_or_create_by!(
test/cases/relation/delegation_test.rb:        :find_or_create_by, :find_or_create_by!, :find_or_initialize_by,
test/cases/relations_test.rb:  def test_find_or_create_by
test/cases/relations_test.rb:    bird = Bird.find_or_create_by(name: "bob")
test/cases/relations_test.rb:    assert_equal bird, Bird.find_or_create_by(name: "bob")
test/cases/relations_test.rb:  def test_find_or_create_by_with_create_with
test/cases/relations_test.rb:    bird = Bird.create_with(color: "green").find_or_create_by(name: "bob")
test/cases/relations_test.rb:    assert_equal bird, Bird.create_with(color: "blue").find_or_create_by(name: "bob")
test/cases/relations_test.rb:  def test_find_or_create_by!
test/cases/relations_test.rb:    assert_raises(ActiveRecord::RecordInvalid) { Bird.find_or_create_by!(color: "green") }
test/cases/statement_cache_test.rb:    def test_find_or_create_by
test/cases/statement_cache_test.rb:      a = Book.find_or_create_by(name: "my book")
test/cases/statement_cache_test.rb:      b = Book.find_or_create_by(name: "my other book")

たくさんヒットしましたが、テストコードやコメントアウトされている行は無視すると下記の2行になります。

lib/active_record/querying.rb:      :find_or_create_by, :find_or_create_by!, :find_or_initialize_by,
lib/active_record/relation.rb:    def find_or_create_by(attributes, &block)

どちらが呼ばれているのでしょうか?
これだけの情報ではわからないのでさらにソースを読む必要がありそうです。

TracePointを使ってみよう

机上でソースを辿るのがツラくなってきたのでTracePointを使って呼び出されたメソッドをトレースしてみます。
動作環境は下記の通り

  • Ruby: 2.6.5
  • Rails: 6.0.2.1

Rails consleで下記を実行しました。

# TracePointのトレース開始
# :callを指定することでメソッド呼び出しをトレースします
trace = TracePoint.trace(:call)  do |tp|
  # 何も指定しないと全Gemがトレースされるのでactiverecordのみ出力するようにしました
  p tp.inspect if tp.path.include?('activerecord-6')
end

# トレースされるようになったのでfind_or_created_byを実行
User.find_or_create_by(name: 'hoge')
# trace情報が大量に出力されます
"#<TracePoint:call `find_or_create_by'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/querying.rb:21>"
"#<TracePoint:call `all'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/scoping/named.rb:26>"
"#<TracePoint:call `current_scope'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/scoping.rb:26>"
"#<TracePoint:call `initialize'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/scoping.rb:74>"
"#<TracePoint:call `value_for'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/scoping.rb:79>"
"#<TracePoint:call `raise_invalid_scope_type!'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/scoping.rb:99>"
"#<TracePoint:call `base_class'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/inheritance.rb:99>"
"#<TracePoint:call `abstract_class?'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/inheritance.rb:161>"
"#<TracePoint:call `relation'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/core.rb:295>"
"#<TracePoint:call `create'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/relation/delegation.rb:114>"
"#<TracePoint:call `relation_class_for'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/relation/delegation.rb:120>"
"#<TracePoint:call `relation_delegate_class'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/relation/delegation.rb:8>"
"#<TracePoint:call `arel_table'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/core.rb:266>"
"#<TracePoint:call `table_name'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/model_schema.rb:195>"
"#<TracePoint:call `reset_table_name'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/model_schema.rb:226>"
"#<TracePoint:call `abstract_class?'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/inheritance.rb:161>"
"#<TracePoint:call `abstract_class?'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/inheritance.rb:161>"
"#<TracePoint:call `table_name'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/model_schema.rb:195>"
"#<TracePoint:call `reset_table_name'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/model_schema.rb:226>"
"#<TracePoint:call `abstract_class?'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/inheritance.rb:161>"
"#<TracePoint:call `table_name='@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/model_schema.rb:205>"
"#<TracePoint:call `compute_table_name'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/model_schema.rb:528>"
"#<TracePoint:call `base_class?'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/inheritance.rb:113>"
"#<TracePoint:call `base_class'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/inheritance.rb:99>"
"#<TracePoint:call `abstract_class?'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/inheritance.rb:161>"
"#<TracePoint:call `full_table_name_prefix'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/model_schema.rb:236>"
"#<TracePoint:call `undecorated_table_name'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/model_schema.rb:522>"
"#<TracePoint:call `full_table_name_suffix'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/model_schema.rb:240>"
"#<TracePoint:call `table_name='@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/model_schema.rb:205>"
"#<TracePoint:call `type_caster'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/core.rb:280>"
"#<TracePoint:call `initialize'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/type_caster/map.rb:6>"
"#<TracePoint:call `initialize'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/arel/table.rb:16>"
"#<TracePoint:call `predicate_builder'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/core.rb:276>"
"#<TracePoint:call `table_metadata'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/core.rb:306>"
"#<TracePoint:call `arel_table'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/core.rb:266>"
"#<TracePoint:call `initialize'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/table_metadata.rb:7>"
"#<TracePoint:call `initialize'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/relation/predicate_builder.rb:7>"
"#<TracePoint:call `initialize'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/relation/predicate_builder/basic_object_handler.rb:6>"
"#<TracePoint:call `register_handler'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/relation/predicate_builder.rb:46>"
"#<TracePoint:call `initialize'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/relation/predicate_builder/base_handler.rb:6>"
"#<TracePoint:call `register_handler'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/relation/predicate_builder.rb:46>"
"#<TracePoint:call `initialize'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/relation/predicate_builder/range_handler.rb:8>"
"#<TracePoint:call `register_handler'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/relation/predicate_builder.rb:46>"
"#<TracePoint:call `register_handler'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/relation/predicate_builder.rb:46>"
"#<TracePoint:call `initialize'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/relation/predicate_builder/array_handler.rb:8>"
"#<TracePoint:call `register_handler'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/relation/predicate_builder.rb:46>"
"#<TracePoint:call `initialize'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/relation/predicate_builder/array_handler.rb:8>"
"#<TracePoint:call `register_handler'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/relation/predicate_builder.rb:46>"
"#<TracePoint:call `initialize'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/relation.rb:27>"
"#<TracePoint:call `finder_needs_type_condition?'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/inheritance.rb:86>"
"#<TracePoint:call `descends_from_active_record?'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/inheritance.rb:76>"
"#<TracePoint:call `abstract_class?'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/inheritance.rb:161>"
"#<TracePoint:call `descends_from_active_record?'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/inheritance.rb:76>"
"#<TracePoint:call `abstract_class?'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/inheritance.rb:161>"
"#<TracePoint:call `default_scoped'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/scoping/named.rb:57>"
"#<TracePoint:call `build_default_scope'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/scoping/default.rb:103>"
"#<TracePoint:call `abstract_class?'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/inheritance.rb:161>"
"#<TracePoint:call `find_or_create_by'@/rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.1/lib/active_record/relation.rb:168>"
・・・(省略)

これで最初に呼ばれているのはlib/active_record/querying.rb#find_or_create_byということがサクッと分かりました。
ちなみに候補に上がっていたlib/active_record/relation.rb#find_or_create_byも58個目に呼ばれていました。

TracePointを使うとこのように速く、そして正確にメソッド呼び出しの順を知ることができます。
この情報をインプットとしてソースを読むとスムーズに処理の流れが読めます。

ちなみに今回は:callを指定して呼び出されたメソッドだけを抽出しましたが、パラメーターや戻り値もトレースできるので、障害の原因調査などでも活躍すると思います。
詳細は"参照"に載せているリンクなどをご覧ください。

参照

以下、TracePointの仕様を知る上でとても参考になったリンクです。

すごく参考になった記事
https://qiita.com/siman/items/9426ff6c113247088f7e

Rubyリファレンス
https://docs.ruby-lang.org/ja/latest/class/TracePoint.html

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

AWS EC2でCould not find pg-1.1.4 in any of the sources Run `bundle install` to install missing gems.と表示される

こんにちは@yukifreeworld12です。
EC2でエラーが出て解決したのでメモとして、そして後世の同じエラーにハマった人の為にも...

1. エラー文

EC2でGitと連携しクローンしていた終盤

$ rake secret
Could not find pg-1.1.4 in any of the sources
Run `bundle install` to install missing gems.

$ gem install pg
Building native extensions.  This could take a while...
ERROR:  Error installing pg:
    ERROR: Failed to build gem native extension.
<略>

To see why this extension failed to compile, please check the mkmf.log which can be found here:

  /home/name/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/extensions/x86_64-linux/2.4.0/pg-1.2.2/mkmf.log

extconf failed, exit code 1

と長いエラー文。

2. 解決策

解決したコマンド
sudo yum install -y postgresql-devel
このコマンドでpostgresql-develをインストールした後
bundle installでちゃんといけました。

3.しかしその後

やりたかったrake secretを実行したところ...

$ rake secret
rake aborted!
Gem::LoadError: You have already activated rake 13.0.1, but your Gemfile requires rake 12.3.3. Prepending `bundle exec` to your command may solve this.

bundle execと書いてあるのでbundle execを付けて
bundle exec rake secretとしたところいけましたとさ。

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

Rails (with sorcery)+ React でOAuth認証を実装してみた

sorceryでRails +SPA構成のOauthサンプルがあまりなさそうなので
参考になれば。

環境

backend

  • rails 6系

front

  • react:16.12
  • react-router: v5系

以下、sorceryのwikiに沿いつつ、SPA用でカスタムしたところを中心に記載
sorcery wiki

今回はgithub と連携してみる


開発の概要

Backend(Rails)

  • oauthからのcallbackを受け取り、ユーザー作成及びログイン処理

Front(React)

  • githubAPIへQueryString形式でパラメーターをもたせてアクセス。
  • 認証後callBackURLにリダイレクトし、Backendに取得したパラメーターを送信


Backend側開発


external module関連のセットアップ

# external moduleのインストール
bundle exec rails g sorcery:install external --only-submodules

# migration
bundle exec rails db:migrate

# 認証用モデル作成
bundle exec rails g model Authentication --migration=false




sorceryのgithub認証関連設定変更

認証用情報の取得
github側の認証設定は以下から行う
https://github.com/settings/developers

initializers/sorcery.rb
Rails.application.config.sorcery.submodules = [:external] #:external追加
Rails.application.config.sorcery.configure do |config|
 ...
  config.github.key = "your github key"
  config.github.secret = "your github secret"
  config.github.callback_url = ""
  config.github.user_info_mapping = {:email => "email" }
  config.github.scope = "user:email"
end



Oauth用controllerの作成

oauths_controller
class OauthsController < ApplicationController
# Frontで取得したToken情報をもとにユーザー認証をするMethod
  def callback
    provider = params[:provider]
    # loginできた場合はここで200を返す
    if @user = login_from(provider)
      render json: { status: 'OK' }
    else
      begin
       # loginできない場合は送られてきた情報をもとにユーザー作成
        @user = create_from(provider)

        reset_session
        auto_login(@user)
        render json: { status: 'OK' }
      rescue
        render json: { status: 'NG' }, status: 400
      end
    end
  end
end



ルーティングの設定

  • wiki記載の内容と異なり、oauth用tokenが送信されてくるAPIのみでOK
config/routes.rb
Rails.application.routes.draw do 
  ...
  post "oauth/callback" => "oauths#callback"
end




Front側開発


関連するルーティングの定義

router.jsx
const AppRouter = () => (
  <Router>
    <Switch>
      <Route
        path="/callback/:provider/"
        component={ExternalAuthCallback}
      />
      <Route path="/sign_in" component={SignIn} />
      <Route component={NotFound} />
    </Switch>
  </Router>
);

export default AppRouter;



サインインページ

  • サインインページの1機能としてGithub認証があるイメージ
  • 通常のRailsのOauthと異なり、callbackされるURLはReactで構成されたSPAのURL(=>"/callback/:provider/")が叩かれることに注意
SignIn.jsx
// CONST.GITHUB.REDIRECT_URL = "http://localhost:3001/callback/github/"

const GITHUB_AUTH_URL = `https://github.com/login/oauth/authorize?client_id=${CONST.GITHUB.APP_ID}&redirect_url=${CONST.GITHUB.REDIRECT_URL}&scope=user:email`;

// ただ queryStringの付与したURLのリンクを踏ませるだけ
const signInForm = () => (
      <div className={styles.submitBox}>
        <Button href={GITHUB_AUTH_URL}>GITHUBで認証</Button>
      </div>
);

export default signInForm;



CallbackURLコンポーネント

  • callback時に叩かれるURLで利用するコンポーネント
  • URLパラメーターでprovider名(今回は"github")を取得
  • callbackURLのquesyStringに付与された認証情報(code=XXXX)を取得
  • ReactからバックエンドAPIへPostする
oAuthCallback/index.jsx
import React, { useState } from "react";
import queryString from "query-string";
import { useHistory } from "react-router";
import { useParams, useLocation } from "react-router-dom";
import { api } from "../../../modules/user";
import Circular from "../../atoms/circular";

const BEFORE = "BEFORE";
const DOING = "DOING";

const ExternalAuth = () => {
  const location = useLocation();
  const history = useHistory();
  const { code = "" } = queryString.parse(location.search);
  const { provider = "" } = useParams();
  const [requestStatus, setRequestStatus] = useState(BEFORE);

  const request = () => {
    setRequestStatus(DOING);
    api.sendExternalAuthRequest({ code, provider }).then(isSuccess => {
      if (isSuccess) {
        history.push("/member/dashboard"); // login後ページ
      } else {
        history.push(PAGE_PATH.AUTH_SIGN_IN); //認証失敗した場合
      }
    });
  };
    
  if (requestStatus === BEFORE) {
    request();
  }
  return (
    <div className={styles.container}>
      <Circular />
    </div>
  );
};

export default ExternalAuth;


Rails APIへのリクエスト

api.js
export const sendExternalAuthRequest = async ({
  code,
  provider
}) => {
  const requester = requestManager.get();
  return requester
    .post(
      "/oauth/callback",
      {
        code,
        provider
      },
    )
    .then(() => true)
    .catch(() => false);
};

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

検索画面の実装【初学者のReact✗Railsアプリ開発 第12回】

やったこと

  • 投稿の検索画面を作成し、検索結果を表示できるようにした。
  • reduxを利用して検索結果を管理。(ページを移動して戻ってくるときに、前の検索結果を表示させるようにしたかった)
  • ページネーションの実装にkaminariを使っている
  • フォームの実装はredux-form

成果物

8gqb6-4o8vr.gif

実装手順(Rails API)

posts_controller

  • 検索を行うコアとなる処理の記述を行っています。
  • content LIKE?の使い方を初めて学びました。
  • %をつけると、あいまい検索になる。無いと、完全一致。検索ワードはクエリでもらってる。
  • ページネーションを使っているので、page_length(何ページまであるか)も返しています。
  • posts_controller
      def search
        posts = Post.where("content LIKE ?", "%#{params[:q]}%").page(params[:page] ||= 1).per(10).order('created_at DESC')
        page_length = Post.where("content LIKE ?", "%#{params[:q]}%").page(params[:page] ||= 1).per(10).total_pages
        json_data = {
          posts: posts,
          page_length: page_length,
        }
        render json: { status: 'SUCCESS', message: 'Loaded the posts', data: json_data}
    
      end
    

route.rb(ルートの編集)

  • 追加します。
route.rb
 get 'search', to: 'posts#search'

実装手順(React)

Search.js(render)

  • 大きく分けて、3つの部品(検索フォーム、検索結果表示部分、ページネーション部分)に分けてレンダリングしている。
  • 検索結果表示部分をthis.renderResults()で、初回訪問かどうか(doneFetch)、結果が存在しているかどうか(noResults)で制御している。
Search.js
class SearchPage extends React.Component {
  render() {
    const { SearchResultsReducer } = this.props;

    const { classes } = this.props;

    return (
      <div>
        <h3>テーマを検索する</h3>
        <SearchForm onSubmit={this.searchPost} />

        {this.renderResults(SearchResultsReducer.noResults, SearchResultsReducer.doneFetch)}
        <MuiThemeProvider theme={pagitheme}>
          <CssBaseline />
          <Pagination
            limit={10}
            offset={SearchResultsReducer.offset}
            total={SearchResultsReducer.page_length * 10}
            onClick={(e, offset) => this.handlePaginationClick(offset)}
          />
        </MuiThemeProvider>
      </div>
    )

  }
}

Search.js(function)

  • doneFetchの取り扱いが頭を使いました。結局、reduxで管理するのが良いと思います。ページ遷移するだけではreduxのstateは変更されないから。
  • 表示の制御は少し頭を使いました。
Search.js
class SearchPage extends React.Component {

  constructor(props) {
    super(props);
    this.searchPost = this.searchPost.bind(this);

  }

  componentDidMount() {
    const { form } = this.props;
    const { SearchResultsReducer } = this.props;

    this.props.actions.getSearchResults(SearchResultsReducer.searchWord, SearchResultsReducer.offset, SearchResultsReducer.doneFetch);
  }
  searchPost = values => {
    const { form } = this.props;
    this.props.actions.getSearchResults(form.SearchForm.values.notes, 0, true);
  }

  handlePaginationClick(offset) {
    const { form } = this.props;
    this.props.actions.getSearchResults(form.SearchForm.values.notes, offset, true);
  }

  renderResults(noResults, doneFetch) {
    const { SearchResultsReducer } = this.props;
    const { classes } = this.props;

    if (!noResults && doneFetch) {
      return (
        <ul className={classes.ul}>
          {SearchResultsReducer.items.map((post) => (
            <Link className={classes.link} to={"/posts/" + post.id}>
              <li className={classes.li} key={post.id}>
                <div className={classes.licontent}>
                  <h3 className={classes.lih3}>{post.content}</h3>
                </div>
              </li>
            </Link>
          ))}
        </ul>
      )
    } else if (!doneFetch) {
      return (
        <h3>検索ワードを入力してください</h3>
      )

    } else {
      return (
        <h3>検索結果はありません。</h3>
      )
    }
  }


  }
}

SearchResultsReducer.js

  • どのタイミングでdoneFetchとnoResultsの状態を変更するかでレンダリング結果が変わってきます。そこに頭を使いました。
SearchResultsReducer.js
const initialState = {
  isFetching: false,
  items: [],
  offset: "",
  page_length: "",
  noResults: false,
  searchWord: "",
  doneFetch: false,
};

const SearchResultsReducer = (state = initialState, action) => {
  switch (action.type) {
    case 'GET_SEARCHRESULTS_REQUEST':
      return {
        ...state,
        isFetching: true,

      };
    case 'GET_SEARCHRESULTS_SUCCESS':
      if (action.items.length === 0) {
        return {
          ...state,
          isFetching: false,
          items: action.items,
          offset: action.offset,
          page_length: action.page_length,
          noResults: true,
          searchWord: action.searchWord,
          doneFetch: action.doneFetch,
          searchWord: action.searchWord,
        };
      } else {
        return {
          ...state,
          isFetching: false,
          items: action.items,
          offset: action.offset,
          page_length: action.page_length,
          noResults: false,
          doneFetch: action.doneFetch,
          searchWord: action.searchWord,

        };
      }

    case 'GET_SEARCHRESULTS_FAILURE':
      return {
        ...state,
        isFetching: false,
        error: action.error,
        searchWord: action.searchWord,
        doneFetch: action.doneFetch,

      };
    default:
      return state;
  }
};

export default SearchResultsReducer;

actions/index.js

index.js
export const getSearchResults = (keyword, offset, doneFetch) => {
  return (dispatch) => {
    dispatch(getSearchResultsRequest())
    const auth_token = localStorage.auth_token
    const client_id = localStorage.client_id
    const uid = localStorage.uid
    const page_url = offset / 10 + 1

    return axios.get(process.env.REACT_APP_API_URL + `/api/v1/search?q=${keyword}&page=${page_url}`, {
      headers: {
        'access-token': auth_token,
        'client': client_id,
        'uid': uid
      }
    })
      .then(response => dispatch(getSearchResultsSuccess(response.data.data.posts, keyword, offset, response.data.data.page_length, doneFetch)))
      .catch(error => dispatch(getSearchResultsFailure(error, keyword, doneFetch)))
  };
};

export const getSearchResultsRequest = () => ({
  type: 'GET_SEARCHRESULTS_REQUEST',
})


export const getSearchResultsSuccess = (json, keyword, offset, page_length, doneFetch) => ({
  type: 'GET_SEARCHRESULTS_SUCCESS',
  items: json,
  offset: offset,
  page_length: page_length,
  searchWord: keyword,
  doneFetch: doneFetch,
})

export const getSearchResultsFailure = (error, keyword, doneFetch) => ({
  type: 'GET_SEARCHRESULTS_FAILURE',
  items: error,
  searchWord: keyword,
  doneFetch: doneFetch,

})

rootReducer.js, SearchForm.js

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

【Rails】id以外の値を主キーに設定して、他のテーブルから参照する

やりたいこと

・usersテーブルの主キーにuser_noを設定(idというフィールドは使わない)
・billsテーブルにcustomer_noを設定する
・userモデルとbillモデルを1対多の関係で紐付ける

既存の(他の人が作った)システムと連動する必要があったので、、、

実装

user

app/model/user.rb
class Customer < ApplicationRecord
  has_many :bills
usersテーブルのmigrationファイル
class CreateUsers < ActiveRecord::Migration[5.2]
  def change
    create_table :users, primary_key: :user_no, autoincrement: false do |t|
      t.string :name
      t.string :email
      t.timestamps
    end
  end
end

bill

app/model/bill.rb
class Bill < ApplicationRecord
  belongs_to :user
end
billsテーブルのmigrationファイル
class RegenerateBills < ActiveRecord::Migration[5.2]
  def change
    create_table :bills do |t|
      t.references :user #外部キー
      t.string :item_name
      t.integer :amount
      t.integer :total
      t.timestamps
    end
  end
end
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

vagrant環境でも、rails s でRailsを動かしたい

環境

Ubuntu 16.04.5 LTS
Windows10
Vagrant

手順

Mac環境でRailsを動かすには、これでいいのだが、

rails s

Vagrant環境では、このように、いちいちコマンドが面倒くさい。

rails s -b 192.168.33.10 -p 3000

履歴から検索して、!してもいいのだが、もっと、サクッと動かしたい。

history | grep 33.10

.bashrcviで開いて、Shift + gで一番下に移動。

vi ~/.bashrc

下記の関数を追加する

function rails () {
  if [[ "$@" =~ ^s$ ]]; then
    command rails s -b 192.168.33.10 -p 3000
  else
    command rails "$@"
  fi
}

変更した、.bashrcを反映させます。

source ~/.bashrc

rails sで、rails s -b 192.168.33.10 -p 3000が動いてくれました。

c'est fini :grin::clap:

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

Rails 初歩

hair_long_manヒゲ.png
       風邪ぎみザッキーさん

Rails 学んでみて

ターミナル実行を行うと、、

テキストの通り再現しているつもりでもlocalhost画面にエラーがよく出てしまいました。
それを正常にするのに手間取ってしまいました。

  白い画面のエラー

  ?ターミナル側に問題がある

  赤い画面のエラー

  ?VScode側に問題がある

  (参考までに)自分が困ってしまった例db migrate

  カラムを作成せずに、そのまま実行を押してしまった場合
             ↓
  カラム作成のコードを書いてからそのまま再実行しても正常に戻らない。。。

       解決策

  rails db:migrate:status = マイグレーションファイルの状態確認
  rails db:rollback = マイグレーションファイルをdownへ変更
  rails db:migrate = マイグレーションファイルをupに変更
             ↓
        カラムを作成できた!

最後に

どの部分が原因のエラーなのか、わからないと詰まる。
それを分かって改善するためにはMVCをしっかり理解していないとできない。

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

railsでレコードに保存している「連続した改行」を改行を保持したままviewに表示する

環境

Rails 5.2.3
Ruby 2.6.3

実現したいこと

例えばArticleモデルのcontentカラムに保存している、
「あいうえお

かきこけこ」
をviewに表示したい。

解決策

safe_join関数を利用し、改行文字をbrに変換する。
simple_format 関数もあるのだが連続改行が認識されないため、
safe_join関数を利用する。

show.html.haml
= safe_join(@article.content.split("\n"), tag(:br))

参照

https://api.rubyonrails.org/classes/ERB/Util.html#method-c-html_escape

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

あっさり読むrails③(クラス)

はじめに

前回の記事にて、
もちろんこのままだと味気ないので、実際にはclassを設定したり、他のメソッドを使用することになりますと書きました。
本記事では、そのclassを設定するの部分を実行しようと思います。

実行

前回使用したコードを改良しようと思います。今の所、コードは下記の通りです。

- @products.each do |product|
  = product.name
  = product.price
  = image_tag(product.image)

まずは、name,price,imageをひとまとめにするクラスを作ります。

index.html.haml
- @products.each do |product|
  .product
    = product.name
    = product.price
    = image_tag(product.image)

これで、テーブルに登録してあるだけの、name,price,imageの3要素を持ったproductclassのオブジェクトが作られます。
この状態では、namepriceでフォントを変えたり、imageの大きさを変更したり、といったことが難しいので、要素毎にclassを設定します。

index.html.haml
- @products.each do |product|
  .product
    = product.name, class: "product__name"
    = product.price, class: "product__price"
    = image_tag(product.image, class: "product__image")
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails6 のちょい足しな新機能を試す 116(MySQL データベース存在チェック編)

はじめに

Rails 6 に追加された新機能を試す第116段。 今回は、MySQL データベース存在チェック 編です。
Rails 6 では、MySQLのデータベースを存在するかどうかをチェックする方法が少し変わりました。
データベースが存在しないときに、 bin/rails db:migrate を実行した場合、 MySQLのエラーメッセージが英語以外でも、 ActiveRecord::NoDatabaseError が発生するようになりました。

Ruby 2.6.5, Rails 6.0.2.1, Rails 5.2.4.1 MySQL 8.0.16 で確認しました。 (Rails 6.0.0 でこの修正が入っています。)

$ rails --version
Rails 6.0.2.1

今回は、MySQL のエラーメッセージを ja_JP にして起動して、 bin/rails db:migrate コマンドを使って Rails 6.0.2.1 と Rails 5.2.4.1 の違いを確認してみます。

今回、MySQL のエラーメッセージを日本語に切り変えるために mysqld コマンドに --lc_messages_dir=/usr/share/mysql-8.0 --lc_messages=ja_JP オプションを追加しています。

MySQL 側で直接、データベースが存在しないことを確認する

mysql コマンドでデータベースが存在しないことを確認します。
ここで、メッセージの先頭が 1049 となっていることに注意してください。

$ mysql -u root app_development
ERROR 1049 (42000): 'app_development' は不明なデータベースです。

db:migrate を実行する

エラーを確認するために、 db:create しないで db:migrate を実行すると ActiveRecord::NoDatabaseError が発生します。

$ bin/rails db:migrate
rails aborted!
ActiveRecord::NoDatabaseError: 'app_development' は不明なデータベースです。
/usr/local/bundle/gems/activerecord-6.0.2.1/lib/active_record/connection_adapters/mysql2_adapter.rb:28:in `rescue in mysql2_connection'

ちなみに MySQL のエラーメッセージが英語の場合も ActiveRecord::NoDatabaseError となります。

$ bin/rails db:migrate
rails aborted!
ActiveRecord::NoDatabaseError: Unknown database 'app_development'
/usr/local/bundle/gems/activerecord-6.0.2.1/lib/active_record/connection_adapters/mysql2_adapter.rb:28:in `rescue in mysql2_connection'

Rails 5 では

MySQL のエラーメッセージが日本語の場合は、 Mysql2::Error となります。 英語の場合は、 ActiveRecord::NoDatabaseError になります。

日本語の場合:

$ bin/rails db:migrate
rails aborted!
Mysql2::Error: 'app_development' は不明なデータベースです。
/usr/local/bundle/gems/mysql2-0.5.2/lib/mysql2/client.rb:90:in `connect'

英語の場合

$ bin/rails db:migrate
rails aborted!
ActiveRecord::NoDatabaseError: Unknown database 'app_development'
/usr/local/bundle/gems/activerecord-5.2.4.1/lib/active_record/connection_adapters/mysql2_adapter.rb:26:in `rescue in mysql2_connection'

何が変わったのか

Rails 5 までは、 エラーメッセージに Unknown database が含まれた場合に ActiveRecord::NoDatabaseError を発生させていました。
Rails 6 では、 エラーメッセージではなく、エラーの番号が 1049 (最初に mysqlコマンドでデータベースが存在しないことを確認したときの値)であるときに、 ActiveRecord::NoDatabaseError を発生させるようにしています。

試したソース

https://github.com/suketa/rails_sandbox/tree/try116_mysql_locale

参考情報

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

deviseで認証メールのリンククリック時に認証しログイン画面に飛ばす

deviseで認証処理を実装時、confirmableのデフォルトだと、認証メールクリック時にActionController::UnknownFormatエラーが出た。

confirmations/showのviewが無い。
認証成功時にログイン画面に飛ばしつつ、認証失敗時にconfirmations/showをエラー画面として表示する。

versionは以下。

ruby: 2.6.5
rails: 6.0.1
devise: 4.7.1

confirmations_controllerのshowメソッドをoverride

controllers/users/confirmations_controller.rb
class Users::ConfirmationsController < Devise::ConfirmationsController
  ...

  # GET /resource/confirmation?confirmation_token=abcdef
  def show
    self.resource = resource_class.find_by_confirmation_token(params[:confirmation_token])

    if resource.nil? || resource.confirmed?
      # トークンが不正な場合、アカウント登録(パスワード登録)が済んでいる場合
      self.resource = resource_class.confirm_by_token(params[:confirmation_token])
      render :show
    elsif resource.is_confirmation_period_expired?
      # アカウント登録メールの期限が切れた場合
      resource.errors.add(:email, :confirmation_period_expired,
        period: Devise::TimeInflector.time_ago_in_words(resource_class.confirm_within.ago))
      render :show
    else
      # activate
      self.resource = resource_class.confirm_by_token(params[:confirmation_token])
      redirect_to new_user_session_path, notice: 'メールアドレスを確認しました。'
    end
  end
  ...
end

メソッド追加

models/users.rb
  def is_confirmation_period_expired?
    # メールアドレス確認メール有効期限チェック(期限はconfig/initializers/devise.rbのconfirm_withinで設定)
    self.confirmation_period_expired?
  end

controllers/show のviewを作成

views/users/confirmations/show.html.erb
<h2>エラー画面</h2>
<% if resource.errors.any? %>
  <article class="message is-danger">
    <div class="message-header">
      <p><%= pluralize(resource.errors.count, "error") %> prohibited this service from being saved:</p>
      <button class="delete" aria-label="delete"></button>
    </div>
    <div class="message-body">
      <ul>
      <% resource.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  </article>
<% end %>

routesも忘れずに

config/routes.rb
...
  devise_for :users,
    controllers: {
      ...
      confirmations: 'users/confirmations',
      ...
    },
...
  devise_scope :user do
    put 'confirmation', to: 'users/confirmations#show', as: :back_confirmation
...
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Ruby on Rails】タイムゾーンの設定を日本時間に変更する方法

タイムゾーンの設定を日本時間に変更する方法です。

設定方法

configの中のapplication.rb内に下記のコードを追加。

config/application.rb
config.time_zone = 'Tokyo'
config.active_record.default_timezone = :local

これだけです。

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

【Rails】開発環境で送信したメールを確認する

letter_opener_webという、ローカル開発環境で送信したメールを確認するgemがすごく便利だったのでまとめます。サーバーのログを辿らずに、ブラウザ上で良い感じにメールを見れます。

Gemfile
group :development do
  gem 'letter_opener_web'
$ bundle install
config/environments/development.rb
# 以下2行を追加
config.action_mailer.default_url_options = { host: 'localhost:3000' }
config.action_mailer.delivery_method = :letter_opener_web
config/routes.rb
# 以下3行を追加
if Rails.env.development?
  mount LetterOpenerWeb::Engine, at: "/letter_opener"
end

localhost:3000/letter_openerにアクセスすると、Gmail風の画面が表示されて、送信メール一覧、詳細を確認することができます。
 2020-01-15 10.01.05.png

参考

letter_opener_webを使ってメール送信をプレビューする

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

超初心者が bundle exec を調べた結果

bundle user って何なんだ?
何かしらをインストールするときにこのコマンドを打っているけど、どういう意味なのかしら?
と思ったので、超初心者なりに調べて要約してみました。
この記事は以下の記事を参考にしています。
https://qiita.com/dawn_628/items/1821d4eef22b9f45eea8#bundler%E3%81%A7%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB%E3%81%97%E3%81%9Fgem%E3%82%92%E7%A2%BA%E8%AA%8D

前提

僕のスペック

  • プログラミング学習を始めて1ヶ月
  • HTML/CSS/Javascript/Ruby/Railsをprogateで一応学習済
  • Ruby on rails でポートフォリオを作成中

開発環境

  • 端末 : LENOVO ideapad 530S-14ARR
  • OS : Windows 10 Home ver.1809
  • シェル : PowerShell 5.1.17763.771
  • Ruby : 2.6.4
  • rails : 5.2.4.1

そもそも bundle とは?

bundler のコマンドの1つだそうで。
bundlerっていうのは「gemの依存関係とバージョンを管理するためのツール」だそうで。
ある1つのgemを使うには、他のgemもインストールする必要がある場合があるらしく、そのような時に、必要なgemもまとめてインストールしてくれる便利なツールがbundlerなのだとか。

(参考:https://www.sejuku.net/blog/19426)

それじゃあ、bundle exec は?

execbundleコマンドのオプションで、このオプションをつけると
自分のあるアプリに対してだけ実行を行ってくれる」
が、execつけないと
「自分の全アプリ(ローカル環境)に対して実行を行う」
ということみたいである。

具体例を考えてみた。

例えば、
bundle installを実行すると、gemfileに記載したgemをインストールしてくれる。
ただ、このインストール先はローカル環境ということになる。
一方、
bundle exec installを実行すると、このインストール先は自分のあるプロジェクト専用のgem格納BOXにぶち込まれる。
ということなのだと思う。

さらに具体例を考えてみよう

今、僕が「tweet_app」と「instagram_app」という2つのアプリを作っていたとしよう。
「tweet_app」にはAというgemを、「instagram_app」にはBというgemを使いたい。
そこで「tweet_app」のgemfileにgem A、「instagram_app」のgemfileにgem Bと記述し、ターミナルからbundle installを行ったとしよう。するとローカル環境にAとBがインストールされてしまうので、それぞれのアプリにはAとB両方のgemの効果が発動してしまうことになる。
この時に、このAとBが揃うと何かしらの悪影響が出てしまうのであれば、これはよくないことである。

このことから基本的にはbundle execを使う方が好ましい気がする。

まとめ

bundle exec installをアプリごとに行うと、それぞれのアプリのgem格納BOXにぶち込まれる。一方でbundle exec installを行うと、すべてのアプリが共有できるところにgemが保管される。

多分bundle exec installを使う方が安全。重複したgemファイルが増えることにもなるので記憶容量の問題がでてくるのかもしれないが。

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

Elastic Beanstalk デプロイする時にハマったところ(Rails)

はじめに

Rails アプリケーションを Elastic Beanstalk を使ってデプロイするときにハマって時間がかかってしまったことを箇条書きにしてみました。
"Elastic Beanstalkはデプロイがすぐできる。"なんて嘘だーと一瞬思いましたが、ポイントさえ押さえておけば、本当に一瞬でデプロイできるようになりますので、ぜひ使ってみてください。

関連リンク

Elastic Beanstalk 関連のリンクを下記に載せておくので、必要であれば参考にしてください。。

Elastic Beanstalk

ハマったところ

  • 文字コード(日本語の場合、utf8 へ変更必要)
  • セキュリティグループ (Mysql2::Error: Can't connect to MySQL server on '**********************' (4))
    • EC2 と RDS を別のVPC/サブネット上に置く方法
    • RDS のセキュリティグループに EC2 からのアクセスを許可する。
  • rails db:createをやってくれない?
    (Mysql2::Error: Unknown Databese'********')

    • 下記コマンドにて、自分でMySQLに接続して、DB作成。
    • MySQL への接続(EC2上で(eb ssh))
      • mysql -h ****MySQLのエンドポイント?(RDS)***** -P 3306 -u sakaes -p
    • DB作成
      • create database **************;
  • initializers/carrierwave.rb 用に beanstalk へS3設定を追記

まとめ

ただエラーを時系列に羅列しただけになってしまいましたが、同じような羅列された情報に自分が助けられたことがあるので、
困っている他の方の役に立てばと思い投稿させていただきました。

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

Rails MySQL データ型をdecimalにしているのに小数点を扱ってくれなくて困った話

目的

  • DBに格納されている数値に少数の値を足そうとして詰まりまくったところをまとめる

困った箇所

  1. viewファイルで表している入力formの少数の値が入力できない。
  2. 入力してDBのカラムに保存しようとしてもカラムに反映されない。saveでのエラーは出ない。
  3. rails consoleで当該カラムに少数を入力しsaveするとDBのカラムに反映されるが、formから少数を入力しても足し算されない、整数の足し算はできる。

「viewファイルで表している入力formの少数の値が入力できない」の解決方法

「入力してDBのカラムに保存しようとしてもカラムに反映されない。saveでのエラーは出ない。」の原因と解決方法

調査と原因

  • 数値を保存するカラムstady_timeのデータ型は少数を扱うためにdecimalにしている。
  • 下記にカラムstady_timeを作成したときのマイグレーションファイルを下記に記載する。
class AddStudyTimeHashTagToPosts < ActiveRecord::Migration[6.0]
  def change
    add_column :posts, :study_time, "decimal"
  end
end
  • 原因はカラム作成時にデータ型"decimal"を設定するときにオプションで格納する数値の全桁数と少数部分の桁数のを指定する必要があったようである。

解決方法

  • カラムstudy_timeを削除して再度オプション付きで作成し直すか、現在存在するstudy_timeカラムのデー型を設定し直すかの二通りがある。
  • 今回は個人プロダクトであるが、デプロイして使用されているサービスで重要なカラムを一旦削除することはありえないため、データ型を設定し直す方法をとる。
  • 下記の修正を加える

    修正前 修正後 修正可否
    カラム名 study_time study_time 修正しない
    データ型 decimal decimal 修正しない
    データ型のオプション 指定なし 全桁数12桁 少数点以下桁数2桁 修正する
  • 下記にカラムのデータ型設定を修正する手順を記載する。

    1. 下記コマンドを実行してマイグレーションファイル(DBの仕様変更を命令するファイル)を作成する。

      $ rails g migration change_study_time
      
    2. db/migrate/に存在するマイグレーションファイル20200112011439_change_study_time.rbに下記の記載を行う(#の行はコメントなので実際はなくても良い)

      class AddPasswordToUsers < ActiveRecord::Migration[6.0]
        def change
          #カラムの変更なのでchenge_column :テーブル名, :カラム名, :データ型, :オプション(数値の全桁数), :オプション(数値の小数点以下の桁数、第二位まで欲しいので2とした)
          change_column :posts, :study_time, :decimal, precision: 12, scale: 2
        end
      end
      
    3. 下記コマンドを実行してマイグレート(DBにマイグレーションファイル通りの仕様変更を行う命令)を行う。

      $ rails db:migrate
      
    4. もしマイグレーションファイルの記載を間違えてマイグレートしてしまったら下記の方法でマイグレート前の状態に戻す。

    5. 下記方法でカラムの方が正常に設定されているか確認する。

rails consoleで当該カラムに少数を入力しsaveするとDBのカラムに反映されるが、formから少数を入力しても足し算されない、整数の足し算はできる。」の原因と解決方法

調査と原因

  • rails consoleでの確認と切り分けにより足し算を行なっているコントローラファイルに問題があると感じた。
  • 少数は表示できるのに足されないということはformから送られてきた値を数値に変換している箇所に間違いがあると予想した。
  • 下記にpostコントローラの当該処理のメソッドを抜粋して記載する。

    def update
      @post = Post.find_by(id: params[:id])
      @post.study_time += params[:study_time].to_i
      @post.save
      redirect_to("/posts/#{@post.id}")
    end
    
  • params[:study_time]にはフォームで入力した少数値が入っている。.to_iで入力した文字列を数値に変更している。

  • 整数を表現する場合は.to_iでも良いが、少数を扱い際は別の表現にしないといけない。

解決方法

  • コントローラでフォームで入力した値を数値をして扱う処理で整数に変換してしまっているので少数も含む数値に変換してあげる記載をする。
  • .to_i.to_fとする。
  • 下記に正しいコードを記載する。

    def update
      @post = Post.find_by(id: params[:id])
      @post.study_time += params[:study_time].to_f
      @post.save
      redirect_to("/posts/#{@post.id}")
    end
    
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rechartsを使って円グラフを表示させるポスト詳細画面の実装【初学者のReact×Railsアプリ開発 第11回】

やったこと

  • Reactでポスト詳細画面を実装した。
  • rechartsを用いて円グラフを表示させた。
  • ログイン中のユーザー情報によって、表示をコントロールした。
  • 詳細画面の中で、投票の変更を行えるようにした。

成果物

jqe0k-3qqon.gif

Rails実装手順

route.rb

route.rb
Rails.application.routes.draw do
 namespace :api, defaults: { format: :json } do
    namespace :v1 do

      delete 'posts/:id', to: 'posts#destroy'

    end
 end
 root 'home#about'
end

likes_controller, posts_controller

すでに実装済み。
Ruby on Rails APIモードのCRUD実装 【初学者のReact✗Railsアプリ開発 第5回】
Ruby on Rails APIモードでいいね機能を実装する【初学者のReact×Railsアプリ開発 第6回】

React実装手順

App.js(ルーティング)

App.js
import PostsDetail from './containers/PostDetail';
            <Auth>
              <Switch>
                <Route exact path="/" component={Home} />
                <Route path='/create' component={Create} />
                <Route path='/postslist' component={PostsList} />
                <Route exact path="/posts/:id" component={PostsDetail} />
              </Switch>
            </Auth>

containers/PostDetail.js(render)

  • 条件によって何を表示するかを分けています。
  • renderGraphWithConditionでは、投票数が1票以上あるときと0票のときで表示内容を分けています。
  • renderButtonWithConditionでは、ログイン中ユーザーのその投稿に対する投票情報で表示を分けています。
  • renderDeleteButtonでは、自分が作成した投稿のとき削除ボタンを表示します。
  • Scrollbars: react-custom-scrollbarsモジュールはめちゃ便利。
PostDetail.js
  render() {
    const { CurrentUserReducer } = this.props;
    const isloggedin = CurrentUserReducer.isLoggedin;

    const { classes } = this.props;
    return (
      <Scrollbars>
        <div className={classes.textLeft}>

          {this.renderGraphWithCondition(this.state.all_count)}
          {this.renderButtonWithCondition(this.state.user_answer_suki)}
          {this.renderDeleteButton()}

        </div>
      </Scrollbars>
    );
  }

containers/PostDetail.js(function)

PostDetail.js
  constructor(props) {
    super(props);
    this.state = {
      user_answer_suki: []
    };
    const auth_token = localStorage.auth_token
    const client_id = localStorage.client_id
    const uid = localStorage.uid
    const { CurrentUserReducer } = this.props;
    axios.get(process.env.REACT_APP_API_URL + `/api/v1/posts/${this.props.match.params.id}`, {
      headers: {
        'access-token': auth_token,
        'client': client_id,
        'uid': uid
      }
    })
      .then((response) => {
        const postdata = response.data.data;
        this.setState({
          suki_percent: postdata.post.suki_percent,
          kirai_percent: 100 - postdata.post.suki_percent,
          suki_count: postdata.post.suki_count,
          kirai_count: postdata.post.kirai_count,
          content: postdata.post.content,
          created_at: postdata.post.created_at,
          all_count: postdata.post.all_count,
          username: postdata.user.name
        });
      })
      .catch(() => {
        this.props.history.push('/')
      });

    axios.get(process.env.REACT_APP_API_URL + `/api/v1/likes/post/${this.props.match.params.id}/user/${uid}`, {
      headers: {
        'access-token': auth_token,
        'client': client_id,
        'uid': uid
      }
    })
      .then((response) => {
        const answereddata = response.data.data;
        this.setState({
          user_answer_suki: answereddata.suki,
          user_answer_updatedat: answereddata.updated_at,
        })
      })

    this.ChangeLike = this.ChangeLike.bind(this);
    this.DeletePost = this.DeletePost.bind(this);
    this.submitLike = this.submitLike.bind(this);

  }

  renderGraphWithCondition(all_count) {
    const { classes } = this.props;
    if (all_count != 0) {
      return (
        <Paper className={classes.root} elevation={1}>
          <Typography variant="headline" component="h1" className={classes.content}>
            {this.state.content}
          </Typography>
          <Typography component="p" style={{ fontWeight: 'bold' }}>
            created by {this.state.username}
          </Typography>
          <PieChart suki_percent={this.state.suki_percent} kirai_percent={this.state.kirai_percent} />
          <Typography component="p" style={{ fontWeight: 'bold', fontSize: '1.2rem' }}>
            スキ: {this.state.suki_percent}% ({this.state.suki_count})
            </Typography>
          <Typography component="p" style={{ fontWeight: 'bold', fontSize: '1.2rem' }}>
            キライ: {this.state.kirai_percent}% ({this.state.kirai_count})
            </Typography>
          <Typography component="p" style={{ fontWeight: 'bold' }}>
            投票数: {this.state.all_count}
            </Typography>
        </Paper>
      )
    } else {
      return (
        <Paper className={classes.root} elevation={1}>
          <Typography variant="headline" component="h1" className={classes.content}>
            {this.state.content}
          </Typography>
          <Typography component="p" style={{ fontWeight: 'bold' }}>
            created by {this.state.username}
          </Typography>
          <Typography component="p" style={{ fontWeight: 'bold' }}>
            まだ誰も投票してません。
          </Typography>
          <Typography component="p" style={{ fontWeight: 'bold' }}>
            投票数: {this.state.all_count}
            </Typography>
        </Paper>
      )
    }
  }

  renderButtonWithCondition(user_answer_suki) {
    const { classes } = this.props;
    if (user_answer_suki == 3) {
      return (
        <Paper className={classes.root}>
          <Button variant="contained" size="large" color="secondary" className={classes.button} onClick={() => this.submitLike(1)}>
            スキ
          </Button>
          <Button variant="contained" size="large" color="primary" className={classes.button} onClick={() => this.submitLike(0)}>
            キライ
          </Button>
        </Paper>
      )
    } else if (user_answer_suki == 2) {
      return (
        <Paper className={classes.root}>
          <Button variant="contained" size="large" color="secondary" className={classes.button} onClick={() => this.ChangeLike(1)}>
            スキ
          </Button>
          <Button variant="contained" size="large" color="primary" className={classes.button} onClick={() => this.ChangeLike(0)}>
            キライ
          </Button>
        </Paper >
      )
    } else if (user_answer_suki == 1) {
      return (
        <Paper className={classes.root}>
          スキで回答済み。
            <Button variant="contained" size="large" color="primary" className={classes.button} onClick={() => this.ChangeLike(0)}>
            キライに変更する
            </Button>
        </Paper>
      )
    } else if (user_answer_suki == 0) {
      return (
        <Paper className={classes.root}>
          キライで回答済み。
            <Button variant="contained" size="large" color="primary" className={classes.button} onClick={() => this.ChangeLike(1)}>
            スキに変更する
            </Button>
        </Paper>

      )
    }
  }

  renderDeleteButton() {
    const { CurrentUserReducer } = this.props;
    const { classes } = this.props;
    if (CurrentUserReducer.items.name === this.state.username) {
      return (
        <Button variant="contained" size="large" color="primary" className={classes.button} onClick={this.DeletePost}>
          このテーマを削除する
        </Button>
      )
    } else {
    }
  }

  ChangeLike(suki) {
    const auth_token = localStorage.auth_token
    const client_id = localStorage.client_id
    const uid = localStorage.uid
    axios.put(process.env.REACT_APP_API_URL + `/api/v1/likes/post/${this.props.match.params.id}`,
      {
        'suki': suki
      },
      {
        headers: {
          'access-token': auth_token,
          'client': client_id,
          'uid': uid
        }
      })
      .then((response) => {
        const postdata = response.data.data;
        this.setState({
          suki_percent: postdata.post.suki_percent,
          kirai_percent: 100 - postdata.post.suki_percent,
          suki_count: postdata.post.suki_count,
          kirai_count: postdata.post.kirai_count,
          content: postdata.post.content,
          created_at: postdata.post.created_at,
          all_count: postdata.post.all_count,
          username: postdata.user.name
        });
        const answereddata = response.data.data.like;
        this.setState({
          user_answer_suki: answereddata.suki,
          user_answer_updatedat: answereddata.updated_at,
        })
      })
  }

  DeletePost() {
    const { CurrentUserReducer } = this.props;
    const auth_token = localStorage.auth_token
    const client_id = localStorage.client_id
    const uid = localStorage.uid
    axios.delete(process.env.REACT_APP_API_URL + `/api/v1/posts/${this.props.match.params.id}`,
      {
        headers: {
          'access-token': auth_token,
          'client': client_id,
          'uid': uid
        }
      })
    window.history.back(-2)
  }

  submitLike(suki) {
    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: this.props.match.params.id,
      suki: suki,
    }
    axios.post(process.env.REACT_APP_API_URL + '/api/v1/likes', data, {
      headers: {
        'access-token': auth_token,
        'client': client_id,
        'uid': uid
      }
    })
      .then((response) => {
        const postdata = response.data.data;
        this.setState({
          suki_percent: postdata.post.suki_percent,
          kirai_percent: 100 - postdata.post.suki_percent,
          suki_count: postdata.post.suki_count,
          kirai_count: postdata.post.kirai_count,
          content: postdata.post.content,
          created_at: postdata.post.created_at,
          all_count: postdata.post.all_count,
          username: postdata.user.name
        });
        const answereddata = response.data.data.like;
        this.setState({
          user_answer_suki: answereddata.suki,
          user_answer_updatedat: answereddata.updated_at,
        })
      })
  }

components/SimplePieChart.js

SimplePieChart.js
import React, { PureComponent } from 'react';
import {
  PieChart, Pie, Sector, Cell,
} from 'recharts';

const COLORS = ['#FF8042', '#0088FE',];

const RADIAN = Math.PI / 180;
const renderCustomizedLabel = ({
  cx, cy, midAngle, innerRadius, outerRadius, percent, index, name
}) => {
  const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
  const x = cx + radius * Math.cos(-midAngle * RADIAN);
  const y = cy + radius * Math.sin(-midAngle * RADIAN);

  return (
    <text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central" style={{ fontWeight: 'bold', whiteSpace: 'pre-line' }}>
      {`${(percent * 100).toFixed(0)}%`}
    </text>
  );
};

export default class SimplePieChart extends PureComponent {
  //static jsfiddleUrl = 'https://jsfiddle.net/alidingling/c9pL8k61/';

  constructor(props) {
    super(props)
  }

  render() {
    const { suki_percent, kirai_percent } = this.props;
    const data = [
      { name: 'スキ', value: suki_percent },
      { name: 'キライ', value: kirai_percent },
    ];
    return (
      <PieChart width={300} height={300}>
        <Pie
          startAngle={90}
          endAngle={-270}
          data={data}
          cx={120}
          cy={120}
          labelLine={false}
          label={renderCustomizedLabel}
          outerRadius={100}
          fill="#8884d8"
          dataKey="value"
        >
          {
            data.map((entry, index) => <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />)
          }
        </Pie>
      </PieChart>
    );
  }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

画像の複数枚投稿と編集とプレビューと私

以前の記事にて

初投稿につき緊張ながら投稿したのをよく覚えています。
たくさん見てくださってありがとうございました。
画像の複数投稿??プレビュー表示??え??

しかし......

「投稿のみやん。editは?はよ。」と多数ご指摘をいただきました。

しかし.......

何の成果もあげられませんでした。

編集することができませんでした。

藁にもすがる思いでメンターさんにコードを見てもらい、アドバイスを仰ぎましたが、

「僕こんな実装したことないし、こんなやり方見たことない。
メンテナンス性も悪いし可読性もよろしくない。
他に効率のいい方法あるから.....やり直そうか」

と、ありがたくも残酷なご指摘をいただきました。

参考にしてくださった方々ごめんなさい。
メンテナンス性が悪く可読性もよくないお粗末なコードを書いてしまいました......

なので今回改良版をやります。意地で。

仕様

  1. 画像は5枚投稿できる
  2. 投稿した画像は1枚ずつプレビューされる
  3. 5枚目を投稿すると投稿欄が消える
  4. ドラッグ&ドロップは非実装
  5. 削除を押すとプレビューが消える

前回の反省

前回は新規投稿に注力しすぎて編集する時のことを考えられていなかったことが敗因でした。
新規投稿が完成した時点でやりきった感がありました。
スプリントレビューで「編集は?」と言われて「Oh.......」ってなったものの対応できず、
「1枚画像が投稿できる状態にする」というレビューOKの最低ラインに合わせるべくJSファイルを消去しました。悲しい。

「後のことを考えて実装する」というもっとも重要な設計思想が抜けていました。
大いに反省するきっかけとなったのでよしとしましょう。

しかし僕は諦めが悪いので、
今回は編集機能の実装を前提として考えていきたいと思います。
ではLET'S GO.

編集するには....

まず、編集ページへアクセスすると、
登録済みの写真についてはプレビューが表示されている状態にしなければいけません。
Image from Gyazo

前回は、画像登録の際にdataTransferというデータの箱を使って、1つのfile_fieldにデータを追加していく形で複数投稿を実装していました。
しかし、いざeditを実装するとなった際に、dataTransferにデータを追加することができず断念する結果となりました......
1つのfile_fieldを酷使する実装にはやはり無理があったようです。無理させてごめんねfile_field。

そこで今回は別々のidを持ったfile_fieldを5つ作成し、そのそれぞれにデータを入れていく形で実装することにしました。
これなら画像の編集も削除もできそうです。
では画像投稿機能から実装していきましょう。

プレビュー表示と削除

前回も利用したsample_appを使用します。

items/new.haml
.main
  %section.main__block
    = form_with model:@item, local:true do |f|
      %h2.sell__block__head
        商品の情報を入力
      .sell__block__form
        .sell__block__form__upload
          %h3.sell__block__form__upload__head
            出品画像
            %span.require 必須
          %p 最大5枚までアップロードできます
          .post__drop__box__container
            .prev-content
            .label-content
              %label{for: "item_images_attributes_0_image", class: "abel-box", id: "label-box--0"}
                %pre.label-box__text-visible クリックしてファイルをアップロード
            .hidden-content
              = f.fields_for :images do |i|
                = i.file_field :image, class: "hidden-field"
                %input{class:"hidden-field", type: "file", name: "item[images_attributes][1][image]", id: "item_images_attributes_1_image" }
                %input{class:"hidden-field", type: "file", name: "item[images_attributes][2][image]", id: "item_images_attributes_2_image" }
                %input{class:"hidden-field", type: "file", name: "item[images_attributes][3][image]", id: "item_images_attributes_3_image" }
                %input{class:"hidden-field", type: "file", name: "item[images_attributes][4][image]", id: "item_images_attributes_4_image" }
        .sell__block__form__name
          .form-group__name
            %label
              商品名
              %span.require 必須
            %div
              = f.text_field :name, placeholder:"商品名(必須 40文字まで)",class: "form__group__name"

        .sell__block__form__btn
          %div
            = f.submit "出品する",class: "btn-default__btn-red"
items.scss
//投稿欄以外のCSSは省略します

//image投稿欄のCSS
.post__drop__box__container{
  display: block;
  margin: 16px auto 0;
  display: flex;
  flex-wrap: wrap;

  //プレビュー表示欄のCSS
  .prev-content {
    display: flex;
    .preview-box {
      height: 162px;
      width: 112px;
      margin: 0 15px 10px 0;
      .upper-box {
        height: 112px;
        width: 100%;
        img{
          width: 112px;
          height: 112px;
        }
      }
      .lower-box {
        display: flex;
        text-align: center;
        .update-box {
          color: #00b0ff;
          width: 50%;
          height: 50px;
          line-height: 50px;
          border: 1px solid #eee;
          background: #f5f5f5;
          cursor: pointer;
        }
        .delete-box {
          color: #00b0ff;
          width: 50%;
          height: 50px;
          line-height: 50px;
          border: 1px solid #eee;
          background: #f5f5f5;
          cursor: pointer;
        }
      }
    }
  }

  //投稿クリックエリアのCSS
  .label-content{
    margin-bottom: 10px;
    width: 620px;
    .label-box {
      display: block;
      border: 1px dashed #ccc;
      position: relative;
      background: #f5f5f5;
      width: 100%;
      height: 162px;
      cursor: pointer;
      &__text-visible {
        position: absolute;
        top: 50%;
        left: 16px;
        right: 16px;
        text-align: center;
        font-size: 14px;
        line-height: 1.5;
        font-weight: bold;
        -webkit-transform: translate(0, -50%);
        transform: translate(0, -50%);
        pointer-events: none;
        white-space: pre-wrap;
        word-wrap: break-word;
      }
    }
  }

  //file_fieldのcss
  .hidden-content {
    .hidden-field {
      display: none;
    }
    .hidden-checkbox {
      display: none;
    }
  }
}

今回は画像を5枚投稿するので、file_fieldを5つ作りました。
全てdisplay: none;で隠しています。
スクリーンショット 2020-01-14 23.36.14.png
こんな感じ。
スクリーンショット 2020-01-14 23.43.16.png

5つのfile_fieldには別のidが振ってあります。
したがって、file_fieldに画像が入るたびにlabel側のforを変更していけば1枚ずつ複数の画像を投稿することができますね。

では、JSで操作してきましょう。

item_new.js
$(document).on('turbolinks:load', function(){
  $(function(){

    //プレビューのhtmlを定義
    function buildHTML(count) {
      var html = `<div class="preview-box" id="preview-box__${count}">
                    <div class="upper-box">
                      <img src="" alt="preview">
                    </div>
                    <div class="lower-box">
                      <div class="update-box">
                        <label class="edit_btn">編集</label>
                      </div>
                      <div class="delete-box" id="delete_btn_${count}">
                        <span>削除</span>
                      </div>
                    </div>
                  </div>`
      return html;
    }

    // ラベルのwidth操作
    function setLabel() {
      //プレビューボックスのwidthを取得し、maxから引くことでラベルのwidthを決定
      var prevContent = $('.label-content').prev();
      labelWidth = (620 - $(prevContent).css('width').replace(/[^0-9]/g, ''));
      $('.label-content').css('width', labelWidth);
    }

    // プレビューの追加
    $(document).on('change', '.hidden-field', function() {
      setLabel();
      //hidden-fieldのidの数値のみ取得
      var id = $(this).attr('id').replace(/[^0-9]/g, '');
      //labelボックスのidとforを更新
      $('.label-box').attr({id: `label-box--${id}`,for: `item_images_attributes_${id}_image`});
      //選択したfileのオブジェクトを取得
      var file = this.files[0];
      var reader = new FileReader();
      //readAsDataURLで指定したFileオブジェクトを読み込む
      reader.readAsDataURL(file);
      //読み込み時に発火するイベント
      reader.onload = function() {
        var image = this.result;
        //プレビューが元々なかった場合はhtmlを追加
        if ($(`#preview-box__${id}`).length == 0) {
          var count = $('.preview-box').length;
          var html = buildHTML(id);
          //ラベルの直前のプレビュー群にプレビューを追加
          var prevContent = $('.label-content').prev();
          $(prevContent).append(html);
        }
        //イメージを追加
        $(`#preview-box__${id} img`).attr('src', `${image}`);
        var count = $('.preview-box').length;
        //プレビューが5個あったらラベルを隠す 
        if (count == 5) { 
          $('.label-content').hide();
        }

        //ラベルのwidth操作
        setLabel();
        //ラベルのidとforの値を変更
        if(count < 5){
          //プレビューの数でラベルのオプションを更新する
          $('.label-box').attr({id: `label-box--${count}`,for: `item_images_attributes_${count}_image`});
        }
      }
    });

    // 画像の削除
    $(document).on('click', '.delete-box', function() {
      var count = $('.preview-box').length;
      setLabel(count);
      //item_images_attributes_${id}_image から${id}に入った数字のみを抽出
      var id = $(this).attr('id').replace(/[^0-9]/g, '');
      //取得したidに該当するプレビューを削除
      $(`#preview-box__${id}`).remove();
      console.log("new")
      //フォームの中身を削除 
      $(`#item_images_attributes_${id}_image`).val("");

      //削除時のラベル操作
      var count = $('.preview-box').length;
      //5個めが消されたらラベルを表示
      if (count == 4) {
        $('.label-content').show();
      }
      setLabel(count);

      if(id < 5){
        //削除された際に、空っぽになったfile_fieldをもう一度入力可能にする
        $('.label-box').attr({id: `label-box--${id}`,for: `item_images_attributes_${id}_image`});
      }
    });
  });
})

プレビュー表示の流れとしては、
(1) id = 0 〜 5のfile_fieldと、for = 0のlabelがある
(2) id = 0のfile_fieldに画像を放り込む
(3) labelのforがJS側で for = 1に変更される
(4) label部をクリックするとid = 1のfile_fieldに画像が入るようになる
(5) 繰り返し 5枚投稿するとlabelが消える
って感じです。
GIFで見るとわかりやすいかも。
Image from Gyazo
......7秒は短い。
しかし、1枚目を投稿したあとでfile_fieldの判定が2つ目に移動しているのがわかりますね。

また、プレビュー削除の流れとしては
(1) プレビューが2つある。それぞれが格納されているfile_fieldのidは0, 1。
(2) id = 0の方の削除ボタンを押す
(3) プレビューが消え、id = 0のfile_fieldの中身が消える
(4) labelのforオプションを0として更新
(5) id = 0のfile_fieldが再度入力可能になる
って感じで実装しています。
GIFで見ると......
Image from Gyazo
削除したfile_fieldが再度入力可能になっているのがわかりますね。
プレビュー表示側ではプレビュー数をもとにlabelのforオプションを更新するよう設定しているので、この後ファイルを追加する際にはちゃんと次の空のfile_fieldに判定が移動する実装になっています。

controller側はというと......

items_controller.rb
class ItemsController < ApplicationController

  def new
    @item = Item.new
    @images = @item.images.build
  end

  def create
    @item = Item.new(item_params)
    @item.save
  end

  private

    def item_params
      params.require(:item).permit(
        :name,
        [images_attributes: [:image]])
    end
end

こんな感じです。サンプルなのでめちゃくちゃシンプルに書いてます。お許しを。
また、DBのカラム名ですが
imagesテーブルに関しては「image」カラムにファイル名が保存される形で実装しています。

これで画像の複数投稿ができるようになりました。良かったね。

編集機能

さて今回の本題です。
編集ページへのアクセス時にどうやってプレビューを出すか......ですが、
わかりやすくeach文で表示することにしました。

edit.haml
.main
  %section.main__block
    = form_with model:@item, local:true do |f|
      %h2.sell__block__head
        商品の情報を入力
      .sell__block__form
        .sell__block__form__upload
          %h3.sell__block__form__upload__head
            出品画像
            %span.require 必須
          %p 最大5枚までアップロードできます
          .post__drop__box__container
            .prev-content

              //JSで挿入したhtmlと同じ形 each文でのプレビュー表示
              - @item.images.each do |image|
                .preview-box
                  .upper-box
                    = image_tag image.image.url, width: "112", height: "112", alt: "preview"
                  .lower-box
                    .update-box
                      %label.edit-btn 編集
                    .delete-box
                      .delete-btn
                        %span 削除
            .label-content

              //プレビューの数に合わせてforオプションを指定
              = f.label :"images_attributes_#{@item.images.length}_image", class: "label-box", id: "label-box--#{@item.images.length}" do
                %pre.label-box__text-visible クリックしてファイルをアップロード
            .hidden-content
              = f.fields_for :images do |i|

                //プレビューが出ている分のfile_fieldとdelete用のチェックボックスを設置
                = i.file_field :image, class: "hidden-field"
                = i.check_box:_destroy, class: "hidden-checkbox"

                //5つのfile_fieldを準備するに当たって、足りない分を表示
              - @item.images.length.upto(4) do |i|
                %input{name: "item[images_attributes][#{i}][image]", id: "item_images_attributes_#{i}_image", class:"hidden-field", type:"file"}
        .sell__block__form__name
          .form-group__name
            %label
              商品名
              %span.require 必須
            %div
              = f.text_field :name, placeholder:"商品名(必須 40文字まで)",class: "form__group__name"

        .sell__block__form__btn
          %div
            = f.submit "出品する",class: "btn-default__btn-red"

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

file_field関連のdisplay: none;を外すとこんな感じになります。
スクリーンショット 2020-01-15 1.46.53.png
ちょっとわかりにくいですが、現在投稿済みのプレビューに対応するfile_fieldの横に削除用のチェックボックスが追加されています。

編集の流れとしては、
(1) id = 0, 1のfile_fieldが投稿済みのものと対応しているものとする
(2) id = 0の削除ボタンを押す
(3) プレビューが削除されるとともに、id = 0のfile_fieldに対応する削除用チェックボックスにチェックが入る
(4) 投稿時と同様、labelをクリックすると削除済みのid = 0のfile_fieldがアクティブになる
(5) 再度id = 0に新しい画像が入ったら、削除用チェックボックスのチェックを外す

こんな感じで実装していきたいと思います。
(4)を見るに、「item_new.js」に追記、場合分けする形でeditも一緒に実装できそうですね。

完成コードはこちら。

item_new.js
$(document).on('turbolinks:load', function(){
  $(function(){

    //プレビューのhtmlを定義
    function buildHTML(count) {
      var html = `<div class="preview-box" id="preview-box__${count}">
                    <div class="upper-box">
                      <img src="" alt="preview">
                    </div>
                    <div class="lower-box">
                      <div class="update-box">
                        <label class="edit_btn">編集</label>
                      </div>
                      <div class="delete-box" id="delete_btn_${count}">
                        <span>削除</span>
                      </div>
                    </div>
                  </div>`
      return html;
    }

    // 投稿編集時
    //items/:i/editページへリンクした際のアクション=======================================
    if (window.location.href.match(/\/items\/\d+\/edit/)){
      //登録済み画像のプレビュー表示欄の要素を取得する
      var prevContent = $('.label-content').prev();
      labelWidth = (620 - $(prevContent).css('width').replace(/[^0-9]/g, ''));
      $('.label-content').css('width', labelWidth);
      //プレビューにidを追加
      $('.preview-box').each(function(index, box){
        $(box).attr('id', `preview-box__${index}`);
      })
      //削除ボタンにidを追加
      $('.delete-box').each(function(index, box){
        $(box).attr('id', `delete_btn_${index}`);
      })
      var count = $('.preview-box').length;
      //プレビューが5あるときは、投稿ボックスを消しておく
      if (count == 5) {
        $('.label-content').hide();
      }
    }
    //=============================================================================

    // ラベルのwidth操作
    function setLabel() {
      //プレビューボックスのwidthを取得し、maxから引くことでラベルのwidthを決定
      var prevContent = $('.label-content').prev();
      labelWidth = (620 - $(prevContent).css('width').replace(/[^0-9]/g, ''));
      $('.label-content').css('width', labelWidth);
    }

    // プレビューの追加
    $(document).on('change', '.hidden-field', function() {
      setLabel();
      //hidden-fieldのidの数値のみ取得
      var id = $(this).attr('id').replace(/[^0-9]/g, '');
      //labelボックスのidとforを更新
      $('.label-box').attr({id: `label-box--${id}`,for: `item_images_attributes_${id}_image`});
      //選択したfileのオブジェクトを取得
      var file = this.files[0];
      var reader = new FileReader();
      //readAsDataURLで指定したFileオブジェクトを読み込む
      reader.readAsDataURL(file);
      //読み込み時に発火するイベント
      reader.onload = function() {
        var image = this.result;
        //プレビューが元々なかった場合はhtmlを追加
        if ($(`#preview-box__${id}`).length == 0) {
          var count = $('.preview-box').length;
          var html = buildHTML(id);
          //ラベルの直前のプレビュー群にプレビューを追加
          var prevContent = $('.label-content').prev();
          $(prevContent).append(html);
        }
        //イメージを追加
        $(`#preview-box__${id} img`).attr('src', `${image}`);
        var count = $('.preview-box').length;
        //プレビューが5個あったらラベルを隠す 
        if (count == 5) { 
          $('.label-content').hide();
        }

        //プレビュー削除したフィールドにdestroy用のチェックボックスがあった場合、チェックを外す=============
        if ($(`#item_images_attributes_${id}__destroy`)){
          $(`#item_images_attributes_${id}__destroy`).prop('checked',false);
        } 
        //=============================================================================

        //ラベルのwidth操作
        setLabel();
        //ラベルのidとforの値を変更
        if(count < 5){
          $('.label-box').attr({id: `label-box--${count}`,for: `item_images_attributes_${count}_image`});
        }
      }
    });

    // 画像の削除
    $(document).on('click', '.delete-box', function() {
      var count = $('.preview-box').length;
      setLabel(count);
      var id = $(this).attr('id').replace(/[^0-9]/g, '');
      $(`#preview-box__${id}`).remove();

      //新規登録時と編集時の場合分け==========================================================

      //新規投稿時
      //削除用チェックボックスの有無で判定
      if ($(`#item_images_attributes_${id}__destroy`).length == 0) {
        //フォームの中身を削除 
        $(`#item_images_attributes_${id}_image`).val("");
        var count = $('.preview-box').length;
        //5個めが消されたらラベルを表示
        if (count == 4) {
          $('.label-content').show();
        }
        setLabel(count);
        if(id < 5){
          $('.label-box').attr({id: `label-box--${id}`,for: `item_images_attributes_${id}_image`});

        }
      } else {

        //投稿編集時
        $(`#item_images_attributes_${id}__destroy`).prop('checked',true);
        //5個めが消されたらラベルを表示
        if (count == 4) {
          $('.label-content').show();
        }

        //ラベルのwidth操作
        setLabel();
        //ラベルのidとforの値を変更
        //削除したプレビューのidによって、ラベルのidを変更する
        if(id < 5){
          $('.label-box').attr({id: `label-box--${id}`,for: `item_images_attributes_${id}_image`});
        }
      }
      //=============================================================================
    });
  });
});
items_controller.rb
class ItemsController < ApplicationController

  #省略

  def edit
    @item = Item.find(params[:id])
  end

  def update
    @item = Item.find(params[:id])
    @item.update(item_update_params)
  end

  private
  def item_params
    params.require(:item).permit(
      :name,
      [images_attributes: [:image]])
  end

  def item_update_params
    params.require(:item).permit(
      :name,
      [images_attributes: [:image, :_destroy, :id]])
  end
end

JSに関しては、コメントアウトの「============================」で囲んであるところが追記箇所です。
わかりにくかったらすみません。

動作はこのようになります。
Image from Gyazo
削除を押すとチェックボックスにチェックが入ります。
このまま『編集する』ボタンを押すと、画像が削除されます。
画像を選択した場合、チェックボックスからチェックが外れます。
この状態で『編集する』ボタンを押すと、画像が差替わります。
Image from Gyazo
編集後のリンク先を指定していないので微妙な感じですが、
each文で引っ張り出される画像が更新されているのがわかりますね。ね??

一旦これで完成です。

はぁ、疲れた。

最後に

とりあえず形はなんとかできました。
まだエラーは残っていると思うので、完璧なものは保証できません。
自分で試した中ではエラー起こらなかったですが、
もし見落としがあったら教えていただけると嬉しいです。

僕ができるのはここまでです。参考にして頂けましたら幸いです。

あとドラッグ&ドロップやら10枚投稿への対応やら2段目のドロップボックス出現やら削除やら色々あると思いますが、
皆さんの手でよしなに実装してください。

また気が向いたら何か書きます。

おわり。

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

【Rails6】gem `rails-erd`を使おうとしたらエラー`Warning: Ignoring invalid association`

はじめに

コマンド入力で手軽にER図が出力できるgem rails-erdを使ってみたときに発生した以下エラーについて解決法を記載します。

$ bundle exec erd --filetype=dot

Loading application in 'app_name'...
Generating entity-relationship diagram for 1 models...
Warning: Ignoring invalid association :posts on User (model Post exists, but is not included in domain)
Warning: Ignoring invalid association :favorites on User (model Favorite exists, but is not included in domain)
Diagram saved to 'erd.dot'.

※使用説明については公式ドキュメントをご参照下さい。(しばらく更新されてないようです)

環境

OS: macOS Catalina 10.15.1
zsh: 5.7.1
Ruby: 2.6.5
Rails: 6.0.2.1

症状

  • usersテーブル
  • postsテーブル
  • favoritesテーブル

元々は上記のようにモデルが3つあり、メインのusersテーブルにその他2つが関連付けされている状態です。

そのテーブル間の関係をER図で確認したかったのですが、冒頭のエラーが発生し、usersテーブルだけのER図(むしろRelationがないのでE図)が出力される状態。

エラーメッセージ

エラーメッセージを詳しく見ると、ここが問題のようです。(Postモデルで抜粋)

Warning: Ignoring invalid association :posts on User (model Post exists, but is not included in domain)

特に以下が問題。
「Postモデルは存在するけど、ドメインに含まれてないよ!」
と書いています。

model Post exists, but is not included in domain

つまり、どうにかしてドメインに含めてしまえば解決しそうです。

ドメインとは?

自分はインターネット上の住所という認識ばかり持っていましたが、そもそもは領域・定義域という意味です。

つまり、今回はrails-erdが認識してくれる領域に該当モデルが入ってくれていないということになります。

なぜ認識してくれないのでしょう?

結論:解決策

config/environments/development.rb
Rails.application.configure do
#...
  config.eager_load = true #元はfalse
#...
end

上記の設定で、無事に全てのモデルが認識され、読み込めるようになります。

理由

こちらの記事より引用させて頂きます。

Railsアプリケーションで、config.eager_load = falseになっていると、そのクラスが存在するか?(定数が存在するか?)を確認しようとしても、クラスにアクセスする前ならばfalseが返ってきます。

どうやらrails-erdが存在確認しようとしても、

config.eager_load = false

だと存在しないよ!と答えてしまい、冒頭のエラーメッセージに繋がってしまうようです。

そのため、

config.eager_load = true

にすると

クラスにアクセスする前から、クラスの存在確認(定数定義の確認)をできるようになっています。

ということですね。

それぞれの挙動の違いもわかりやすく書かれていた記事だったので、ご興味ある方はご確認下さい。
助かりました:bow_tone1:

おわりに

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

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

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

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

Unknown action ,The action 'create' could not be found for MessagesControllerの一例

1.どんなエラー

MessagesControllerにcreateが定義されていませんよという内容です。
ちなみに筆者は下記の通りcreateは定義していました。

<エラー文>

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

<エラーに該当するファイル>

messages_controller.rb
class MessagesController < ApplicationController
  before_action :set_group

  def index
    @message = Message.new
    @messages = @group.messages.includes(:user)
  end
end

  def create
    @message = @group.messages.new(message_params)
    if @message.save
      redirect_to group_messages_path(@group), notice: 'メッセージが送信されました'
    else
      @messages = @group.messages.includes(:user)
      flash.now[:alert] = 'メッセージを入力してください。'
      render :index
    end
  end

  private

  def message_params
    params.require(:message).permit(:content, :image).merge(user_id: current_user.id)
  end

  def set_group
    @group = Group.find(params[:group_id])
  end

2.原因

今回の場合は'class MessagesController'の'end'の位置が'create'の定義の前で記入してしまっていたため、定義されていないという状態となっていました。
なので八行目の'end'を一番下にカットアンドペーストすることで解決します。

3.類似ケース

ちなみに下記のurlでは変数名を間違っていたため、同様なエラーとなっていますが、筆者も変数'message'を'messages'としているというミスがあったため、そちらも確認していただければいいのかなと思います
<参考にした記事>
https://teratail.com/questions/83406

<筆者が変数で間違えていた箇所>

_message.html.haml
.messages   ←この変数が複数形になっていました
  .chat-main__message-list
    .chat-main__message-list__name
      =message.user.name
      .chat-main__message-list__name__date
        =message.created_at.strftime("%y年%m月%d日%H時%M分")
    .chat-main__message-list__comment
      - if message.content.present?
        %p.chat-main__message-list__comment__content
          = message.content
          = image_tag message.image.url, class: 'chat-main__message-list__comment__content__image' if message.image.present?

ご参考になればと思います

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