Ruby on Rails 触って難しかったところメモ

 Ruby on Rails を触ってみて、実装に苦労した点を備忘録として残しておく。ChatGPT君に聞いたらスラスラ教えてくれるもんだからかなり助かった。

親のプルダウンによって子のプルダウンの内容が変わるとき

 大カテゴリを選択したとしてそれに紐づく中カテゴリのプルダウンの内容を切り替える必要がある。例えば、first_categories.id = 1 に紐づくsecond_categories.id に 1, 2, 3 があって、first_categories.id = 2 に紐づく second_categories.id に 4, 5, 6 があった場合、first_categories.id で 1 を選択したなら、second_categories.id のプルダウンのリストは、1, 2, 3 である必要がある。

<div class="col-md-12">
	<%= render 'shared/form_label', form: form, attribute: :first_category_id, label: "大カテゴリ名", required: true %>
	<%= form.collection_select :first_category_id, FirstCategory.order(:id), :id, :name, { prompt: "---" }, { class: "form-select#{' is-invalid' if form.object.errors[:first_category].any? || form.object.errors[:first_category_id].any?}" } %>
	<%= render 'shared/error_message', form: form, attribute: [ :first_category, :first_category_id ] %>
</div>

<div class="col-md-12">
	<%= render 'shared/form_label', form: form, attribute: :second_category_id, label: "中カテゴリ名", required: true %>
	<%= form.collection_select :second_category_id, [], :id, :name, { prompt: "---" }, { class: "form-select#{' is-invalid' if form.object.errors[:second_category].any? || form.object.errors[:second_category_id].any?}" } %>
	<%= render 'shared/error_message', form: form, attribute: [ :second_category, :second_category_id ] %>
</div>
<script>
    const firstSelect = document.getElementById('product_first_category_id');
    const secondSelect = document.getElementById('product_second_category_id');

    // Railsの変数をJSONに変換してJavaScriptに渡す
    const secondCategories = <%= raw((@second_categories || []).to_json) %>;
    // 大カテゴリごとに振り分けた中カテゴリの配列
    const optionsByFirst = {};

    // 大カテゴリごとに紐づいている中カテゴリの配列を作成
    secondCategories.forEach(secondCategory => {
        // 大カテゴリのキーを設定する
        if (!optionsByFirst[secondCategory.first_category_id]) {
            optionsByFirst[secondCategory.first_category_id] = [];
        }

        // 同じ大カテゴリを持つ中カテゴリのデータを大カテゴリのキーごとに格納する
        optionsByFirst[secondCategory.first_category_id].push({
            value: secondCategory.id,
            text: secondCategory.name
        });
    });

    // 入力エラー等で画面が表示されたときに選択されていた大カテゴリと中カテゴリを再現する
    document.addEventListener('DOMContentLoaded', function() {
        const firstOption = firstSelect.value;
        // 大カテゴリが選択されている場合
        if (firstOption) {
            // 大カテゴリのキーから中カテゴリの選択肢群を指定する
            const secondOptions = optionsByFirst[firstOption] || [];
            // 中カテゴリのプルダウンを初期化
            secondSelect.innerHTML = '';
            // デフォルトの選択肢を設定
            const newOption = document.createElement('option');
            newOption.value = '';
            newOption.text = '---';
            secondSelect.appendChild(newOption);

            // 中カテゴリの選択肢群をプルダウンに追加していく
            secondOptions.forEach(item => {
                const newOption = document.createElement('option');
                newOption.value = item.value;
                newOption.text = item.text;
                // もし中カテゴリを選択していたならその中カテゴリを選択済みにする
                if ("<%= @product.second_category_id %>" == item.value) {
                    newOption.setAttribute('selected', 'selected');
                }
                secondSelect.appendChild(newOption);
            });
        } else {
            // 大カテゴリが選択されていない、もしくは入力エラーがあった場合
            secondSelect.innerHTML = '';

            const newOption = document.createElement('option');
            newOption.value = '';
            newOption.text = '---';
            secondSelect.appendChild(newOption);
        }
    });

    // 大カテゴリが変更された場合、それに紐づいた中カテゴリのプルダウンを用意する
    firstSelect.addEventListener('change', function() {
        const firstOption = firstSelect.value;

        // 存在する大カテゴリが選択された場合
        if (firstOption) {
            const secondOptions = optionsByFirst[firstOption] || [];

            secondSelect.innerHTML = '';

            const newOption = document.createElement('option');
            newOption.value = '';
            newOption.text = '---';
            secondSelect.appendChild(newOption);

            secondOptions.forEach(item => {
                const newOption = document.createElement('option');
                newOption.value = item.value;
                newOption.text = item.text;
                secondSelect.appendChild(newOption);
            });
        } else {
            // 空の大カテゴリが選択された場合
            secondSelect.innerHTML = '';

            const newOption = document.createElement('option');
            newOption.value = '';
            newOption.text = '---';
            secondSelect.appendChild(newOption);
        }
    });
</script>

CSVダウンロード処理

 一覧画面に検索ボタンとCSVダウンロードボタンがある想定。検索条件に合ったレコードのCSVダウンロード処理ができる。

def index
    # --- CSVダウンロードボタンが押された場合 ---
    if params[:csv_export] == "csv_export"
        # 検索条件をセッションに保存
        session[:csv_export] = params[:q]   # 検索条件だけを保存する例

        # csv_export アクションへリダイレクト
        redirect_to csv_export_admin_contacts_path and return
    end

    @q = Contact.ransack(params[:q])
    @contacts = @q.result.page(params[:page]).order(id: :desc).per(ContactConstants::ADMIN_PAGENATE_LIST_LIMIT)
end

def csv_export
    # セッションから検索条件を取り出す
    params[:q] = session[:csv_export] || {}
    contacts = Contact.ransack(params[:q]).result.order(id: :asc)

    file_name = "お問い合わせ"

    # from/to の値を取り出す
    from = params[:q]["created_at_gteq"]
    to   = params[:q]["created_at_lteq"]

    # 条件分岐でファイル名を組み立て
    if from.present? && to.present?
        file_name += "#{from}~#{to}.csv"
    elsif from.present?
        file_name += "#{from}~.csv"
    elsif to.present?
        file_name += "~#{to}.csv"
    else
        file_name += ".csv"
    end

    # CSV生成 (Shift_JIS)
    csv_data = CSV.generate do |csv|
        csv << %w[お問い合わせNO 投稿日 氏名 メールアドレス お問い合わせ内容 ステータス]
        contacts.find_each do |c|
            csv << [ c.no, I18n.l(c.created_at, format: "%Y/%m/%d %H:%M:%S"), c.user.name, c.user.email, c.body, ContactConstants::STATUS_LIST[c.status] ]
        end
    end
    send_data csv_data.encode(Encoding::CP932, invalid: :replace, undef: :replace),
              filename: file_name,
              type: "text/csv; charset=Shift_JIS"
end
  1. csv_data = CSV.generate do |csv| … end
    • Ruby 標準ライブラリの CSV クラスを使って、CSV 形式のデータを文字列として生成。
    • CSV.generate はブロックの中で csv << […] のように書くとそれぞれの行をCSVとして連結した文字列を返す。
  2. csv << %w[お問い合わせNO 投稿日 氏名 メールアドレス お問い合わせ内容 ステータス]
    • %w[…] は Ruby のリテラル構文で、スペース区切りの文字列を配列に変換する。
    • %w[お問い合わせNO 投稿日 氏名]は [“お問い合わせNO”, “投稿日”, “氏名”]となる
    • お問い合わせNO,投稿日,氏名,メールアドレス,お問い合わせ内容,ステータスいうヘッダー行がCSVに追加される。
  3. contacts.find_each do |c|
    • 検索結果 contacts のレコードを1件ずつ取り出して処理しています。
    • .find_each は ActiveRecord のメソッドで、大量データでもメモリを圧迫しないようにバッチ処理(デフォルトでは1000件ずつ)で取り出す。
  4. csv << [ c.no, I18n.l(c.created_at, format: “%Y/%m/%d %H:%M:%S”), c.user.name, c.user.email, c.body, ContactConstants::STATUS_LIST[c.status] ]
    • CSVの1行分のデータ(各カラムの内容)を配列として追加しています。
  5. send_data csv_data.encode(Encoding::CP932, invalid: :replace, undef: :replace), …
    • 生成したCSV文字列を実際にダウンロード可能なレスポンスとして送信する。
    • send_data は Rails のメソッドで、「文字列やバイナリデータをHTTPレスポンスとして直接送信」するもの。
    • 引数にCSV文字列を渡すと、ブラウザが「ファイルをダウンロードする」と認識する。
  6. .encode(Encoding::CP932, invalid: :replace, undef: :replace)
    • 生成されたCSVはRuby内部的にUTF-8エンコーディングだが、WindowsのExcelで開けるようにするために、Shift_JIS(CP932)に変換している。
    • invalid: :replace, undef: :replace は「変換できない文字があってもエラーにせず、代わりに置き換える」という指定になる。

CSVアップロード処理

def csv_import
    uploaded_file = params[:csv_file]
    if uploaded_file.blank?
        redirect_to csv_upload_admin_first_categories_path, alert: "CSVファイルを選択してください" and return
    end

    invalid_rows  = []
    valid_records = []
    names_seen = Set.new

    begin
        # --- 検証フェーズ ---
        CSV.foreach(uploaded_file.tempfile.path, headers: true, encoding: "SJIS:UTF-8").with_index(2) do |row, line|
            name = row["大カテゴリ名"].to_s.strip
            # ファイル内で重複している場合はエラー扱い
            if names_seen.include?(name)
                invalid_rows << { line: line, errors: "CSVファイル内で重複している大カテゴリ名があります" }
            end

            names_seen.add(name)

            record = FirstCategory.new(name: name)
            if record.valid?
                valid_records << record
            else
                invalid_rows << { line: line, errors: record.errors.full_messages.first }
            end
        end
    rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError => _
        # 文字コードが違う場合のエラーをキャッチ
        redirect_to csv_upload_admin_first_categories_path, alert: "CSVファイルの文字コードがシフトJISではありません" and return
    end

    if invalid_rows.any?
        # エラーがあれば再表示
        flash.now[:alert] = "エラーがあります。修正して再アップロードしてください"
        @errors = invalid_rows
        render :csv_upload and return
    end

    # --- バルクINSERT ---
    FirstCategory.import valid_records   # activerecord-import
    redirect_to admin_first_categories_path, notice: "大カテゴリを#{valid_records.size}件登録しました"
end
  1. CSV.foreach(uploaded_file.tempfile.path, headers: true, encoding: “SJIS:UTF-8”).with_index(2)
    • CSV.foreach はファイルを1行ずつ読み込みながら処理するメソッド。
    • headers: true で、CSVの1行目をヘッダーとして扱う。
    • encoding: “SJIS:UTF-8” はhift_JIS で書かれた日本語CSVをUTF-8に変換して読み込む指定。
    • 1行目がヘッダー行なので、with_index(2) により行番号を 2行目からカウントする。
  2. record = FirstCategory.new(name: name)
    • FirstCategory.new で仮のレコードを作り、Railsのモデルバリデーションでチェック。
    • 有効なら valid_records に入れ、不正なら invalid_rows にエラーを記録。
  3. rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError => _
    • もしファイルがUTF-8や他の文字コードで保存されていた場合、文字化けや変換エラーが起きるのでエラー表示する。
  4. FirstCategory.import valid_records
    • activerecord-import は複数レコードをまとめて1回のSQLでINSERTできるGem。

確認画面がある入力フォーム

def new
    # セッションから復元
    if session[:contact].present?
        @contact = Contact.new(session[:contact])
    else
        @contact = Contact.new
    end
end

def confirm
    @contact = Contact.new(contact_params)
    @contact.user_id = current_user.id
    # render plain: @contact.errors.full_messages and return

    # バリデーションエラーがある場合は入力画面に戻す
    if @contact.invalid?
        render :new
    else
        # セッションに保存(DB保存はまだしない)
        session[:contact] = @contact.attributes
    end
end

def create
    if params[:back]
        # 確認画面から「戻る」が押された場合
        redirect_to new_contact_path
        return
    end

    @contact = Contact.new(session[:contact])
    @contact.status = ContactConstants::STATUS_NOT_STARTED # 未対応固定

    if @contact.save
        ContactMailer.notify_user(@contact, current_user).deliver_later

        # 完了画面用に番号をセッションに退避
        session[:last_contact_no] = @contact.no

        # 入力用セッションは破棄
        session.delete(:contact)

        redirect_to complete_contacts_path
    else
        render :new
    end
end

def complete
    if session[:last_contact_no].present?
        @contact_no = session[:last_contact_no]
        session.delete(:last_contact_no)
    else
        redirect_to root_path, alert: "セッション期限が切れました。"
    end
end

一括削除処理

def bulk_destroy
    if params[:product_ids].present?
        begin
            Product.transaction do
                Product.where(id: params[:product_ids]).find_each(&:destroy!)
            end
            redirect_to admin_products_path, notice: "選択した製品情報を削除しました。"
        rescue => e
            Rails.logger.error("一括削除エラー: #{e.class} - #{e.message}")
            redirect_to admin_products_path, alert: "削除処理中にエラーが発生したため、削除は行われませんでした。"
        end
    else
        redirect_to admin_products_path, alert: "削除する製品情報を選択してください。"
    end
end

m : n の関係の検索処理

has_many :product_tags, dependent: :destroy
has_many :tags, through: :product_tags

# タグで絞り込み(すべて持つ)
scope :by_tags, ->(tag_ids) {
    tag_ids = Array(tag_ids).reject(&:blank?) # 空文字やnilを削除
    if tag_ids.present?
        joins(:product_tags)
        .where(product_tags: { tag_id: tag_ids })
        .group("products.id")
        .having("COUNT(DISTINCT product_tags.tag_id) = ?", tag_ids.size)
    end
}
  1. scope :by_tags, ->(tag_ids) {
    • scope … ActiveRecordの検索条件を定義するための機能。
    • ->(tag_ids) … 無名関数(ラムダ)。呼び出し時に Product.by_tags([1, 2, 3]) のように tag_ids を渡す。
  2. tag_ids = Array(tag_ids).reject(&:blank?) # 空文字やnilを削除
    • Array(tag_ids) … 引数が単体でも配列でも必ず配列化します(例:nil → [], 1 → [1])。
    • .reject(&:blank?) … 空文字列や nil を取り除く。
  3. joins(:product_tags)
    • products テーブルと中間テーブル product_tags を内部結合(INNER JOIN)する。
  4. .where(product_tags: { tag_id: tag_ids })
    • product_tags.tag_id が、指定された tag_ids のいずれかに一致するレコードを絞り込む。
  5. .group(“products.id”)
    • GROUP BY によって商品ごとにまとめる。
  6. .having(“COUNT(DISTINCT product_tags.tag_id) = ?”, tag_ids.size)
    • HAVING 句を使って、「その商品が持つタグの数」が指定されたタグ数と一致する商品だけに絞り込む。
    • つまり、タグ [1, 2, 3] が指定された場合、タグ1・2・3のすべてを持つ商品のみが残る。

複数のカラムに対してスペースありの検索ワードで検索する

# 公開側キーワード検索
scope :by_keyword, ->(keyword) {
    if keyword.present?
        # 全角スペースを半角スペースに変換
        keyword = keyword.tr(" ", " ")
        # 前後のスペース削除
        keyword = keyword.strip
        # 連続する半角スペースを1つに
        keyword = keyword.gsub(/\s+/, " ")
        # 分割
        keywords = keyword.split(" ")

        # SQL文を parts にためていく
        sql_parts = []
        # バインド用の値を values にためていく
        values = []

        keywords.each do |word|
            # 例: (name LIKE ? OR detail LIKE ?)
            sql_parts << "(name LIKE ? OR detail LIKE ?)"
            # プレースホルダに入れる値を追加
            values << "%#{word}%"
            values << "%#{word}%"
        end

        # 複数キーワードの場合は AND でつなげる
        sql = sql_parts.join(" AND ")

        # 例: WHERE (name LIKE ? OR detail LIKE ?) AND (name LIKE ? OR detail LIKE ?)
        where(sql, *values)
    end
}
  1. sql_parts << “(name LIKE ? OR detail LIKE ?)”
    • SQL文の条件を部分的に作る。
    • 各キーワードに対して、「name カラムまたは detail カラムのどちらかに一致する」という条件を () でまとめている。
  2. values << “%#{word}%”
    • プレースホルダ(?)に入れる実際の値を配列に追加。
    • 2つあるので同じワードを2回入れる。
  3. “%” は部分一致(LIKE検索)のワイルドカード。
    • つまり “%りんご%” は「りんごを含む文字列」にマッチする。
  4. sql = sql_parts.join(” AND “)
    • それぞれのキーワード条件を AND でつなげる。
    • つまり「すべてのキーワードが含まれている」データを検索する。
    • WHERE (name LIKE ? OR detail LIKE ?) AND (name LIKE ? OR detail LIKE ?)
  5. where(sql, *values)
    • 最後に where にSQL文字列と値を渡して検索を実行する。
    • *values は配列を展開する(スプラット演算子)ので、where に複数引数として渡される。
    • values = [“%りんご%”, “%りんご%”, “%ジュース%”, “%ジュース%”] の場合、where(sql, *values) は where(sql, “%りんご%”, “%りんご%”, “%ジュース%”, “%ジュース%”) と同じ

コメント

タイトルとURLをコピーしました