P学習帳

書いておぼえるブログ

Ruby on RailsでアップロードしたCSVを読み込んでDBに書き込む

やりたいこと

ローカルからCSVをWEBアプリにアップロードし、DBに書き込む。CSVファイルは文字コードがcp962を想定している。

コード

View

= form_tag csv_upload_path, multipart: true do
    = file_field_tag :file
    = submit_tag 'アップロード'

これはhamlcsv_upload_pathのところはactionに相当するものを書く。アクション先のエイリアスはアプリのルートに/infoを付けるか、コンソールでrake routesとやってルーティングの一覧が得られる。

CSVファイルをアップロードするとき、ファイルそのものが特定のモデルと対応するわけではないので、form_tagを使うのがよいのだと思う。

file_field_tagの引数:fileは、HTMLのnameタグに変換される。コントローラーでこのテキストエリアの内容を受け取るには、params['file']とすればいい。

Controller

次、コントローラー。ビューから送ったCSVファイルを受け取る。params[:file]でファイル情報が取れる。
ここではProductモデルがあるものとし、このモデルに書いたimportメソッドでファイル内容の読み出しと書き込みをする。
そのあとのリダイレクトでは、ビューに表示するメッセージをnoticeに渡している。こうすることで、ビューにnoticeに対応するスニペットを書いておけば、コントローラーで指定したメッセージを画面に表示させることができる。

  def import_btob
    Product.import(params[:file])
    redirect_to root_url, notice: "CSVファイルをアップロードしました。"
  end

Model

コントローラーから呼び出すメソッドを書く。CSVファイルの読み込みとDBへの書き込みをおこなう。

ここでは、読み込みと書き込みのメソッドを別ける。importメソッドとopen_spreadsheetメソッドである。

class Product < ApplicationRecord  

def self.import(file)
    spreadsheet = open_spreadsheet(file)
    header = ["goods_identification_type", "goods_label", "goods_id", "option_1_id", "option_2_id", "selling_price", "quantity", "option_uniq_code", "jan_code"]

    ActiveRecord::Base.transaction do
      (2..spreadsheet.count).each do |i|
        if !spreadsheet[i].nil?
          row = Hash[[header, spreadsheet[i] ].transpose]
          option = Product.new(row)
          option.save!
        end
      end
    end
  end

肝は、Hash[ [header, spreadsheet[i] ].transpose]だ。こうすると、ヘッダーの要素名をキー、CSVのレコード各要素をバリューとするハッシュができる。それをnewすることでDBにレコードを書き込める。

読み込み処理はこちらでおこなう。

  def self.open_spreadsheet(file)
    case File.extname(file.original_filename)
    when '.csv'  then
      CSV.parse(File.read(file.path, encoding: 'cp932').encode("UTF-8", :invalid => :replace))
    else raise "Unknown file type: #{file.original_filename}"
    end
  end

end

CSVの読み込みを一行でやっている。このスニペットはQiitaから拾った。とてもいい。

CSV.parse(File.read(file.path, encoding: 'cp932').encode("UTF-8", :invalid => :replace))

エンコードをcp932にしたのは、はじめRooというgemで読み込もうとしたところ、失敗したからだ。

Perl入学式 東京第3回、ピザパーティのお題を解く

Perl入学式 東京の第3回にサポーターとして参加させてもらいました。勉強会終了後のピザパーティーで出たお題をやってみたので、そのことについて書いてみます。

やりたいこと

グーグルの検索トレンドデータからキーワードランキングをつくります。ランキングに加えて前日からの推移も含めます。

Googleトレンドのデータは以下のエンドポイントからJSON形式で取れます。

https://trends.google.com/trends/api/dailytrends?geo=JP&ed=yyyymmdd

わかばたいむすさんの記事に詳しくあります。
wakabatimes.com

やってみてわかったこと

・順位の差を求めることができませんでした。前日だとランキング圏外になっていたからです。
Googleトレンドをみると正しいランキングがすぐわかります。 Google Trends

コード

月初の順位前日比を求めるところと結果の出力はさぼっています。

use strict;
use warnings;
use feature 'say';
binmode(STDOUT, ":utf8");
use JSON::XS qw/decode_json/;
use LWP::Simple;
use DateTime;
use DDP;

# Googleトレンドのエンドポイントにいれる日付をyyyymmddの形式でつくる
my $dt = DateTime->now( time_zone => 'Asia/Tokyo' );
my $yyyymmdd = $dt->ymd('/');
my ($y, $m, $d) = split(/\//, $yyyymmdd);
my @days = ();

# 月初から本日までの日にちを配列にいれる(例. 1,2,3...)
if ( $d ne "1" ) {
  my $c = 1;
  my @array;
  $array[$d - 1] = '';
  @days = map { $c++; } @array;
} else {
  @days[0] = "1";
}

# yyyymmdd形式の文字列に変換する
my @dates = ();
for my $day ( @days ) {
  my $dt = DateTime->new(
    time_zone => 'Asia/Tokyo',
    year      => $y,
    month     => $m,
    day       => $day,
  );
  push(@dates, $dt->ymd(''));
}

# 配列に用意した各日付のデータをGoogleに問い合わせる
my $rankings = {};
for my $target_day (@dates) {
  say "day => $target_day";
  my $rank = 1;
  my $content = get("https://trends.google.com/trends/api/dailytrends?geo=JP&ed=$target_day");
  my $json = substr($content, 6); # 1行目のごみを取り除く
  my $data = decode_json($json);

  # 検索ワードのデータまで掘る
  my $article_array_ref = $data->{default}->{trendingSearchesDays}[0]->{trendingSearches};

  # 検索ワードはランク順にならんでいるので、ループしながらランクを付けられる
  for my $article ( @$article_array_ref ) {
    my $query = $article->{title}{query};
    say $query;
    $rankings->{$target_day}{$query}{rank} = $rank;
    $rank++;
  }
}

# ランキングにくわえて前日との順位差を入れるハッシュレフを別につくる
my $trends = $rankings;
my @dates_copy = @dates;
shift @dates_copy;
my @from_second_day_to_later_days = @dates_copy;
my $prev_day = shift @dates;

# 検索ワードごとに前日のランクとの差を求める(ただし、前日だとランク圏外になっていて存在しなかった)
for my $target_day (@from_second_day_to_later_days) {
  for my $query ( keys %{$rankings->{$target_day}} ) {
    my $trend;
    if ( defined($rankings->{$prev_day}{$query}) ) {
      $trend = $rankings->{$prev_day}{$query} - $rankings->{$target_day}{$query};
    } else {
      $trend = "new";
    }
    $trends->{$target_day}{$query}{trend} = $trend;
  }
}

# デバッグプリント
p $trends;

標準出力(一部抜粋)

20190109   {
        NGT            {
            rank    10,
            trend   "new"
        },
        りんご病           {
            rank    15,
            trend   "new"
        },
        アジアカップ         {
            rank    9,
            trend   "new"
        },
        サッカーアジアカップ     {
            rank    7,
            trend   "new"
        },
        サッカー日本代表       {
            rank    5,
            trend   "new"
        },
        トルクメニスタン       {
            rank    3,
            trend   "new"
        },
        バーチャルさんは見ている   {
            rank    14,
            trend   "new"
        },
        ピーチジョン         {
            rank    13,
            trend   "new"
        },
        兼高かおる          {
            rank    6,
            trend   "new"
        },
        友井雄亮           {
            rank    2,
            trend   "new"
        },
        家売る女           {
            rank    8,
            trend   "new"
        },
        尾田栄一郎          {
            rank    4,
            trend   "new"
        },
        山口真帆           {
            rank    1,
            trend   "new"
        },
        志村けん           {
            rank    12,
            trend   "new"
        },
        摂津正            {
            rank    17,
            trend   "new"
        },
        武田玲奈           {
            rank    18,
            trend   "new"
        },
        玉川徹            {
            rank    16,
            trend   "new"
        },
        馬毛島            {
            rank    11,
            trend   "new"
        }
    },
    20190110   {
        はあちゅう             {
            rank    17,
            trend   "new"
        },
        スキャンダル専門弁護士       {
            rank    5,
            trend   "new"
        },
        ディビジョン            {
            rank    18,
            trend   "new"
        },
        ワンピース             {
            rank    4,
            trend   "new"
        },
        京王観光              {
            rank    12,
            trend   "new"
        },
        刑事ゼロ              {
            rank    10,
            trend   "new"
        },
        宇賀なつみ             {
            rank    11,
            trend   "new"
        },
        川谷絵音              {
            rank    13,
            trend   "new"
        },
        '日本 対 トルクメニスタン'   {
            rank    3,
            trend   "new"
        },
        星座                {
            rank    9,
            trend   "new"
        },
        松本人志              {
            rank    15,
            trend   "new"
        },
        森川葵               {
            rank    14,
            trend   "new"
        },
        楠ろあ               {
            rank    7,
            trend   "new"
        },
        盾の勇者の成り上がり        {
            rank    6,
            trend   "new"
        },
        福男                {
            rank    8,
            trend   "new"
        },
        竹下亘               {
            rank    16,
            trend   "new"
        },
        純烈                {
            rank    1,
            trend   -33088
        },
        雨宮萌果              {
            rank    2,
            trend   "new"
        }

【Ruby】MiniTestでテストを書いてみる

目的

MiniTestをとりあえず使ってみること。
Effective Ruby 第6章「テスティング」の内容そのままを試してみた結果をまとめる。

テストの対象

たとえばバージョンを管理するクラスversion.rbがある。このクラスのメソッドをテストしたい。

version.rb

class Version
  attr_reader (:major, :minor, :patch)

  def initialize(version)
    @major, @minor, @pathch = version.split('.').map(:map.to_i)
  end
end

使用例:

require File.dirname(__FILE__) + "/Version"

def major_number
  v = Version.new('2.1.3') # major: 2, minor: 1, patch: 3
  v.major # => 2
end

以下ではこのクラスのmajorの値をテストするテストケースを書く。

 手順

1 テストスクリプト命名

version.rbのテストなのでversion_test.rbという命名しておく。 この通りでなくてもよいて、命名が一貫していればOKだ。

2 必要なライブラリを読み込む

MiniTestを実行するために必要となる。

ライブラリ全体を読み込むには以下の1行を書く。

require 'minitest/autorun'

3 テスト用クラスをつくる

テスト本体のクラスをVersionTest、MiniTestのクラスをMiniTest::Unit::TestCaseとしてクラスを作る。

class VersionTest < MiniTest::Unit::TestCase
  # テストケース
end

4 テストを書く

version_test.rbにテストを書いてく。

require('minitest/autorun')        # MiniTestのライブラリを読み込む
require File.dirname(__FILE__) + '/Version' # version.rbを読み込む

class VersionTest < MiniTest::Unit::TestCase
  # テストケース
  def test_major_number
    v = Version.new('2.1.3')
    assert_equal(2, v.major) 
end

5 実行する

以上でテストが完成した。テスト対象のスクリプトとテストスクリプトは同じディレクトリに置かれている。

$ ls
version.rb      version_test.rb

いよいよ実行する。

$ ruby version_test.rb

.
.
.

成功例: 成功した場合とくに何も言われない。0 failuresというところでエラーがなかったことがわかるようになっている。

$ ruby version_test.rb
MiniTest::Unit::TestCase is now Minitest::Test. From version_test.rb:4:in `<main>'
Run options: --seed 48129

# Running:

.

Finished in 0.000882s, 1133.7868 runs/s, 1133.7868 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

失敗例: わざと失敗するようにテストを以下のように書き換えてみる。

assert_equal(1, v.major) 
MiniTest::Unit::TestCase is now Minitest::Test. From version_test.rb:4:in `<main>'
Run options: --seed 52817

# Running:

F

Failure:
VersionTest#test_major_number [version_test.rb:8]:
Expected: 1
  Actual: 2


bin/rails test version_test.rb:6



Finished in 0.001389s, 719.9424 runs/s, 719.9424 assertions/s.
1 runs, 1 assertions, 1 failures, 0 errors, 0 skips

assert_equalメソッドの第一引数がExpectedv.majorの値がActualで、両者が異なってますよ、ということが表示される。

感想

テストは大切。いちどテストを書いてしまえば、テスト対象が多くてもOK。それに対して、手動でプリントデバッグするのは消耗する。また、変更したあとの再テストがかんたんにできるのも魅力。

ハッシュレファレンスをsprintfでフォーマットしてprintする

課題

こういうハッシュレフを

my $my_info = {
  name => 'Masato',
  tall => 169,
  mass => 55,
  food => 'Sushi',
};

こうやってプリントしたい。

Name         Tall    Mass    Age     Food
Masato       169     55      33      Sushi

やり方

sprintf関数とハッシュスライスを使う。

sprintf関数の使い方

説明は以下のページがわかりやすい。

sprintf関数 - 文字列の書式指定 - Perlゼミ(サンプルコードPerl入門)

まず、ヘッダーをsprintfで出すにはこうすればできる。

  my $format = "%-12s %-7s %-7s %-7s %-7s";
  printf $format, qw(Name Tall Mass Age Food);

$formatはsprintf関数の第一引数に渡す書式だ。%-12sなどというのは左詰のxx固定長と解釈される。よってこの例は左詰で12文字の固定長になる。長さは実際にプリントする文字列に合わせる。

ハッシュスライス  

  printf $format, @$hash_ref{ qw(name tall mass age food) };

javascriptで変数が定義済みかどうかをチェックする

ポイントは、JSでは0が偽になる点です。0は偽ではありますが、未定義ではありません。

if (height) { ... } の条件では0が未定義と判定されてしまいます。0の場合にTrueのブロックに入るようheight == 0を追加しましょう。

var height = 0;

if (heigth || height == 0) {

  console.log('Variable is defined.');

} else {

  console.log('Variable has NOT been defined.');

}

【Rails】空配列がTrue判定になってはまった件

状況

ActiveRecordでクエリを出して戻り値を変数にいれる。その変数が空かどうかで条件分岐させたい。

解法

books = Book.where(title: 'Great novel')
if books.present?
  # booksの中身がある場合に実行する
end

まちがい

books = Book.where(title: 'Great novel') # 該当レコードがない場合 [] (空配列)が返る
if books # 空配列はTrueになる
  # あれ?
end

考察

Rubyではnilfalse以外がすべてTrueになる。つまり、''[]はTrueだ。空配列がfalseになる言語のノリで書いているとハマる。

【SQL】selectで * を使うとおそい

状況

Active Recordのselectで*でテーブルのすべてのカラムを選択するようにしたらどことなく遅くなった。

改善  

*で全部のカラムを返すのではなくて、実際に使う値のカラムを指定するようにした。

前:

Book.select('books.*')

あと:

Book.select('books.title, books.price, books.author, books.isbn')

テーブルのカラムが大量にあればあるほど、明示的に指定して絞れた場合のスピードアップは大きい。

感想  

業務でうえのようにカラムを指定したら2〜3倍クエリが速くなった。かんたんに速度の向上が実現できてよい。