ActiveRecord::Base.unscope の呼び出しでネストが深くなっているのを浅くする

仕事で見かけたコードを汎用化した内容にして、個人ブログにメモっておく。*1

多段ネストを reverse して Proc でラップしたり、それを RSpec で検証する方法はブログ書いてないと忘れる自信がある。

モデルの定義

class User < ApplicationRecord
  default_scope { where(deleted_at: nil) }
end

class Article < ApplicationRecord
  default_scope { where.not(published_at: nil) }
end

元コード

User.unscoped do
  Article.unscoped do
      assert_equal 'SELECT "users".* FROM "users"', User.all.to_sql
      assert_equal 'SELECT "articles".* FROM "articles"', Article.all.to_sql
  end
end

書き換えたあと

module ScopeHelper
  def self.unscoped(models:, &block)
    models.reverse
      .inject(block) { |result, model| -> { model.unscoped(&result) } }
      .call
  end
end

ScopeHelper.unscoped(models: [User, Article]) do
  assert_equal 'SELECT "users".* FROM "users"', User.all.to_sql
  assert_equal 'SELECT "articles".* FROM "articles"', Article.all.to_sql
end

RSpecで書いたテスト

RSpec.describe ScopeHelper do
  describe '.unscoped' do
    it do
      expect(User).to receive(:unscoped).and_call_original
      block = -> {}
      expect(Article).to receive(:unscoped) do |&args|
        expect(args).to eq block
      end

      ScopeHelper.unscoped(models: [User, Article], &block)
    end
  end
end

*1:default_scopeとか絶対使わないけど、他に汎用化した例は思い浮かばなかった

2019年11月現在のRailsのissueに関するメモ #heiseirb

平成.rb #10Railsのissueに取り組むきっかけが分からない人向けのメモです。

個人的に気になっているissueについて簡単にまとめました。

注意)特に初心者向けとかではありません。

STIhas_secure_password の組合せで、親クラスでvalidationsを切り替えたい?

Trouble using has_secure_password validations attribute with STI models https://github.com/rails/rails/issues/37755

  • 再現コードなし
  • 登録者のコードは意図通りじゃなさそう
    • self.classClass を返すから常に true

scope + new でエラーが出る

raise_on_type_mismatch! in ActiveRecord::Associations::BelongsToAssociation https://github.com/rails/rails/issues/37752

  • 再現コードなし
  • コミッターの反応あり

ignored_columns + from で意図しないカラムが無視される

Ignored columns is ignoring virtual columns selected by .from https://github.com/rails/rails/issues/37745

  • 再現コードなし

through と polymorphic の組合せで外部キーがnilになる

Assigning with a through association and a self-ref polymorphic association leaves a nil foreign key https://github.com/rails/rails/issues/37758

  • 再現コードなし

request.variant の挙動が5.2と6.0で違う

Rails 6.0.0 chooses variants differently from 5.2 when variants are using different template engines https://github.com/rails/rails/issues/37021

  • 再現コードなし
  • リグレッション
    • 5.2 の挙動がバグっていた可能性もある
  • stale ラベル

enum で文字列を代入したらエラーになる

Revisiting the inconsistency in enums: raises ArgumentError when making an assignment with invalid value, but returns wrong results when querying https://github.com/rails/rails/issues/37630

  • 再現コードなし

accepts_nested_attributes_for でモデルを削除してもメモリ上に残る

Removing association with dependent: :destroy, through nested attributes leaves entity from intermediate table in memory https://github.com/rails/rails/issues/37649

  • 再現コードあり

DateTime#advance の挙動が5.2と6.0で違う?

DateTime#advance() expats integer params https://github.com/rails/rails/issues/37425

  • 再現コードなし

joins と pluck をあわせて使うと型が変わる

Unexpected Type Casting in ActiveRecord::Calculations#pluck https://github.com/rails/rails/issues/28044

  • 再現コードあり
  • 直すのは難しそう

テーブルない状態で order を呼ぶと5.2からエラー?

Calling "order" crashes when the model isn't represented by a database table https://github.com/rails/rails/issues/37741

  • 再現コードあり
  • 仕様かバグかの判断から必要そう

belongs_to と scope の組合せでsaveに失敗する

belongs_to with scope cause racord save fails https://github.com/rails/rails/issues/36990

  • 再現コードなし
  • stale ラベル

Rustでwebアプリを実装して勉強 その5 - テンプレートを使ってhtmlを返す

Rust初心者が勉強したことを記録する備忘録。

今日やった事

今日はRustやる時間が少ないので、handlebarsを使ってhtmlを返すところを少しだけ試した。

github.com

handlebars の使い方

actix-webのexamplesを参考にして、そのままコピペした。

// Handlebars uses a repository for the compiled templates. This object must be
// shared between the application threads, and is therefore passed to the
// Application Builder as an atomic reference-counted pointer.

サンプルコードのコメントに書いてあるように、Handlebars のインスタンスは各スレッドで共有するために web::Data を使う。

html を検証する

html を assert_eq! で検証するのは大変なので、部分一致で確認するように修正した。

- assert_eq!(test::read_body(resp), Bytes::from_static(b"Hello world!"));
+ let html = str::from_utf8(&test::read_body(resp))
+    .expect("Could not convert response to UTF8")
+     .to_string();
+ assert!(html.contains("<h1>Hello world!</h1>"));

& をつけたり、str や String の関係がよく分かってないが、とりあえずこんな感じで書くことで部分一致を検証できた。

次にやりたいこと

試したいことはだいたいできたので、次はコードを整理したい。

  • SQLに関する処理をModelに移す(もしくはRepositoryを作る)
  • テストコードを書く
  • jsonを返すように直す
  • テンプレートエンジンを使ってhtmlを返す
  • テスト環境の改善(コードのリファクタリングトランザクション周りの設定など)
  • Dockerで動くように直す
  • Herokuで動かす

Rustでwebアプリを実装して勉強 その4 - jsonを返すAPIの実装

Rust初心者が勉強したことを記録する備忘録。

今日やった事

今日やったことをプルリクにしておくと参照するのが楽なことに気づいた。

github.com

JSONシリアライズ/デシリアライズ

serde_json一択です。
引用: isucon7予選のアプリをRustに移植したから解説するね

という記事を見たので serde_json を使ってみた。

serde_json の使い方

jsonにしたいstructにSerialize, Deserializeをつける。

#[derive(Queryable, Serialize, Deserialize)]
pub struct Post {
    pub id: i32,
    pub title: String,
    pub body: String,
    pub published: bool,
}

あとは serde_json::to_string や serde_json::from_str を呼べばいい。

let post = Post { ...(略) };

// struct -> json
let json = serde_json::to_string(&post).unwrap();

// json -> struct
let json_post: Post = serde_json::from_str(&json).unwrap();

ルーティング

ルーティングは 前方一致 なので、 "/posts/{id}" より前に書く必要がある。

 cfg.route("/posts", web::get().to(posts::index))
+    .route("/posts/{id}.json", web::get().to(posts::show_json))
     .route("/posts/{id}", web::get().to(posts::show))

本当は content-type などを考慮して良い感じに書くべきなんだろうけど、今日は疲れたので雑に別の関数にした。

次にやりたいこと

  • SQLに関する処理をModelに移す(もしくはRepositoryを作る)
  • テストコードを書く
  • jsonを返すように直す
  • テンプレートエンジンを使ってhtmlを返す
  • テスト環境の改善(コードのリファクタリングトランザクション周りの設定など)
  • Dockerで動くように直す
  • Herokuで動かす

Rustでwebアプリを実装して勉強 その3 - テストコードを書く

Rust初心者が勉強したことを記録する備忘録。

github.com

今日やった事

参考ページ

単体テストを書く

Rustではテストコードを同じファイル内に書く文化らしい。

まずは、 src/models/post.rs にDBに関係しないところを試しに書いてみた。

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn unit_sample() {
        let post = Post {
            id: 1,
            title: "title".to_string(),
            body: "body".to_string(),
            published: false,
        };

        assert_eq!(post.id, 1);
        assert_eq!(post.title, "title");
        assert_eq!(post.body, "body");
        assert_eq!(post.published, false);
    }
}

DB周りはテスト実行後にロールバックとかする必要あるので、また別の日に挑戦したい。

結合テストを書く

tests/main.rs を作成し、HTTPリクエストを投げて、 200 OK などを確認する最低限のテストを書いた。

#[cfg(test)]
mod tests {
    extern crate rust_web;

    use actix_web::dev::Service;
    use actix_web::http::StatusCode;
    use actix_web::{test, App};
    use bytes::Bytes;
    use rust_web::routes;

    #[test]
    fn get_root() {
        let mut app =
            test::init_service(App::new().configure(routes::top).configure(routes::posts));
        let req = test::TestRequest::get().uri("/").to_request();
        let resp = test::block_on(app.call(req)).unwrap();

        assert_eq!(resp.status(), StatusCode::OK);
        assert_eq!(test::read_body(resp), Bytes::from_static(b"Hello world!"));
    }

    #[test]
    fn post_posts() {
        let mut app =
            test::init_service(App::new().configure(routes::top).configure(routes::posts));
        let req = test::TestRequest::post().uri("/posts").to_request();
        let resp = test::block_on(app.call(req)).unwrap();

        assert_eq!(resp.status(), StatusCode::CREATED);
        assert_eq!(test::read_body(resp), Bytes::from_static(b"Inserting"));
    }
}

CircleCIを設定する

テストコードを書いたのでCircleCIの設定もしておいた。

version: 2.1
jobs:
  build:
    docker:
      - image: circleci/rust
        environment:
          DATABASE_URL: test.sqlite3
    steps:
      - checkout
      - run: cargo fmt -- --check
      - run: cargo install diesel_cli
      - run: diesel setup
      - run: cargo test

次にやりたいこと

  • SQLに関する処理をModelに移す(もしくはRepositoryを作る)
  • テストコードを書く
  • jsonを返すように直す
  • テンプレートエンジンを使ってhtmlを返す

Rustでwebアプリを実装して勉強 その2 - SQLの処理をモデルに書く

Rust初心者が勉強したことを記録する備忘録。

github.com

今日やったこと

main.rs に処理を全部書いて微妙だったので、MVCっぽい感じでディレクトリ構造を整理した。

独学なので、この構成で良いのかは全く自信がない。

ディレクトリ構成

ルーティング、モデル、コントローラーが別ファイルになるようにしている。

src
├── controllers
│   ├── mod.rs
│   ├── posts.rs
│   └── top.rs
├── lib.rs
├── main.rs
├── models
│   ├── mod.rs
│   ├── post.rs
│   └── util.rs
├── routes.rs
└── schema.rs

ルーティング

ルーティングを縦に列挙するのは辛いので、リソース単位で関数化した。

fn main() {
    HttpServer::new(|| App::new().configure(routes::top).configure(routes::posts))
        .bind("127.0.0.1:8088")
        .unwrap()
        .run()
        .unwrap();
}

routes.rs は以下のような感じで実装してある。

use crate::controllers::{posts, top};
use actix_web::web;

pub fn top(cfg: &mut web::ServiceConfig) {
    cfg.route("/", web::get().to(top::index));
}

pub fn posts(cfg: &mut web::ServiceConfig) {
    cfg.route("/posts", web::get().to(posts::index))
        .route("/posts/{id}", web::get().to(posts::show))
        .route("/posts", web::post().to(posts::create))
        .route("/posts/{id}", web::put().to(posts::update))
        .route("/posts/{id}", web::patch().to(posts::update))
        .route("/posts/{id}", web::delete().to(posts::destroy));
}

コントローラー

Rails のコントローラーのような感じでメソッドを作っている。

use crate::models::Post;
use actix_web::{HttpRequest, HttpResponse, Responder};

pub fn index() -> impl Responder {
    let results = Post::all();
    let res = format!("Displaying {} posts", results.len());

    HttpResponse::Ok().body(res)
}

pub fn show(req: HttpRequest) -> impl Responder {
    let post = find_post(req);
    let res = format!("Show {}", post.id);

    HttpResponse::Ok().body(res)
}

pub fn create() -> impl Responder {
    Post::create("title", "body");

    HttpResponse::Created().body("Inserting")
}

pub fn update(req: HttpRequest) -> impl Responder {
    let mut post = find_post(req);
    post.publish();

    HttpResponse::Ok().body("Published")
}

pub fn destroy(req: HttpRequest) -> impl Responder {
    let post = find_post(req);
    post.destroy();

    HttpResponse::NoContent()
}

fn find_post(req: HttpRequest) -> Post {
    let id: i32 = req.match_info().get("id").unwrap().parse().unwrap();
    Post::find(id)
}

モデル

main.rsにあった処理をActiveRecordっぽいAPIで使えるように実装した。

ただ、細かいところは適当。

use super::util::establish_connection;
use crate::schema::posts;
use crate::schema::posts::dsl;
use diesel::prelude::*;

#[derive(Queryable)]
pub struct Post {
    pub id: i32,
    pub title: String,
    pub body: String,
    pub published: bool,
}

#[derive(Insertable)]
#[table_name = "posts"]
struct NewPost<'a> {
    pub title: &'a str,
    pub body: &'a str,
}

impl Post {
    pub fn all() -> Vec<Post> {
        let connection = establish_connection();
        dsl::posts
            .filter(dsl::published.eq(true))
            .limit(5)
            .load::<Post>(&connection)
            .expect("Error loading posts")
    }

    pub fn find(id: i32) -> Post {
        let connection = establish_connection();
        dsl::posts
            .find(id)
            .first::<Post>(&connection)
            .expect("Error finding posts")
    }

    pub fn create(title: &str, body: &str) {
        let new_post = NewPost {
            title: title,
            body: body,
        };
        let connection = establish_connection();
        diesel::insert_into(posts::table)
            .values(&new_post)
            .execute(&connection)
            .expect("Error saving new post");
    }

    pub fn publish(&mut self) {
        let connection = establish_connection();
        let num_updated = diesel::update(dsl::posts.find(self.id))
            .set(dsl::published.eq(true))
            .execute(&connection)
            .expect(&format!("Unable to find post {}", self.id));

        if num_updated > 0 {
            self.published = true;
        }
    }

    pub fn destroy(&self) {
        let connection = establish_connection();
        diesel::delete(dsl::posts.find(self.id))
            .execute(&connection)
            .expect("Error deleting posts");
    }
}

次にやりたいこと

  • SQLに関する処理をModelに移す(もしくはRepositoryを作る)
  • テストコードを書く
  • jsonを返すように直す
  • テンプレートエンジンを使ってhtmlを返す

Rustでwebアプリを実装して勉強 その1 - sqliteを使う

Rustを勉強するためにactix-webを使ってwebアプリを書いてみた備忘録。

github.com

今日やったこと

Rust初心者で何も分からないので、actix-web と diesel のGetting Startedを読みながら、雰囲気でコードを書いてみた。

diesel_cli のインストール

DBの作成・マイグレーションなどをするため、cliをインストールする必要がある。

$ cargo install diesel_cli

で普通にインストールできた。 もし postgresql とかがローカルになければ、sqlite だけのインストールもできるらしい。

$ cargo install diesel_cli --no-default-features --features sqlite

DBの作成

DATABASE_URLを指定して diesel setup するとDBが作れる。

$ DATABASE_URL=development.sqlite3 diesel setup

毎回指定するのも面倒なので、.envrc に入れておく。

$ echo export DATABASE_URL=development.sqlite3 > .envrc

マイグレーションファイルの作成

diesel migration generate で作成できる。

$ diesel migration generate create_posts
Creating migrations/2019-10-27-152149_create_posts/up.sql
Creating migrations/2019-10-27-152149_create_posts/down.sql

ActiveRecordみたいにRubyではなくて、生SQLだった。この潔さは嫌いじゃない。

DBのマイグレーション

diesel migration runマイグレーションを実行してくれる。

$ diesel migration run
Running migration 2019-10-27-152149_create_posts

diesel migration redo で down.sql の動作確認もできる。

DBの接続

こんな感じで connection を作る。

pub fn establish_connection() -> SqliteConnection {
    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    SqliteConnection::establish(&database_url).expect(&format!("Error connecting to {}", database_url))
}

コネクションの管理を diesel でやっているのか、自分でやる必要あるのかはよく分かっていない。

複数のPostを取得する

こんな感じでfilterやlimitが書けるらしい。たぶんorderとかも使える気がする。

let results = posts
    .filter(published.eq(true))
    .limit(5)
    .load::<Post>(&connection)
    .expect("Error loading posts");

1つのPostを取得する

findのあとにfirstを書くのが冗長感あるけど、たぶん posts.find(id) がWHERE句になるんだと思う。

let post = posts.find(id)
    .first::<Post>(&connection)
    .expect("Error finding posts");

Postを登録する

insert_intoを使う。

ちなみにdieselのGetting Started だとget_resultを使っているけど、sqliteは対応してないので代わりにexecuteを使っている。

let new_post = NewPost {
    title: "title",
    body: "body",
};
let connection = establish_connection();
diesel::insert_into(posts::table)
    .values(&new_post)
    .execute(&connection)
    .expect("Error saving new post");

Postを更新する

Getting Startedを参考にして書いたら、普通に動いた。

diesel::update(posts.find(id))
    .set(published.eq(true))
    .execute(&connection)
    .expect(&format!("Unable to find post {}", id));

Postを削除する

これもドキュメント通り。

diesel::delete(posts.find(id))
    .execute(&connection)
    .expect("Error deleting posts");

ルーティング周り

それっぽく書いたら、動いた。

HttpServer::new(|| {
    App::new()
        .route("/", web::get().to(index))
        .route("/posts", web::get().to(posts_index))
        .route("/posts/{id}", web::get().to(posts_show))
        .route("/posts", web::post().to(posts_create))
        .route("/posts/{id}", web::put().to(posts_update))
        .route("/posts/{id}", web::patch().to(posts_update))
        .route("/posts/{id}", web::delete().to(posts_destroy))
})

次にやりたいこと

  • SQLに関する処理をModelに移す(もしくはRepositoryを作る)
  • テストコードを書く
  • jsonを返すように直す
  • テンプレートエンジンを使ってhtmlを返す