削除しない掲示板

## 掲示板を作る

誰しもが
一度は作る
掲示板

https://github.com/hanachin/kesenai_tsumi

@sinsoku さんの [NULL嫌いのUPDATEしないDB設計 #DBSekkeiNight / DB design without updating](https://speakerdeck.com/sinsoku/db-design-without-updating) を読んで最近自分も似た感じの設計をしたなあと思い出したので覚えているうちに書く。

## Railsアプリを作る

便利そうなので `--edge` を付けておく

```console
% rbenv shell 2.7.1
% gem install --pre rails
% rails new --edge --database=postgresql --skip-bundle --skip-webpack-install kesenai_tsumi
% cd kesenai_tsumi
```

いい感じに `docker-compose.yml`

```docker-compose.yml
version: "3.8"

services:
  app: &rails
    build: .
    depends_on:
      - db
    environment:
      DATABASE_URL: postgres://postgres:hi@db:5432
      RAILS_ENV: development
      WEBPACKER_DEV_SERVER_HOST: webpacker
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - ./home:/home/app
      - bundle:/app/.bundle
      - node_modules:/app/node_modules
  webpacker:
    <<: *rails
    command: webpack-dev-server
    environment:
      WEBPACKER_DEV_SERVER_HOST: 0.0.0.0
    ports:
      - "3035:3035"
  db:
    image: postgres:12.3-alpine
    environment:
      POSTGRES_PASSWORD: hi
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  bundle:
  node_modules:
  pgdata:
```

と `Dockerfile` 

```Dockerfile
FROM rubylang/ruby:2.7.1-bionic

RUN apt-get update -qq && \
  apt-get install -y curl gnupg && \
  bash -c 'curl -fsSL https://deb.nodesource.com/setup_14.x | bash -' && \
  bash -c 'curl -fsSL https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -' && \
  echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list && \
  apt-get update -qq && \
  apt-get install -y nodejs postgresql-client build-essential libpq-dev yarn && \
  groupadd --non-unique --gid 1000 app && \
  useradd --system --non-unique --create-home --uid 1000 --gid 1000 app && \
  mkdir -p /app/.bundle && \
  mkdir -p /app/node_modules && chown -R app:app /app

COPY entrypoint.sh /usr/bin
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
USER app

WORKDIR /app
ENV PATH /app/bin:$PATH
EXPOSE 3000
CMD ["rails", "server", "--binding", "0.0.0.0", "--port", "3000"]
```

と `entrypoint.sh`

```entrypoint.sh
#!/bin/bash
set -e

rm -f /app/tmp/pids/server.pid

exec "$@"
```

そして `bin/setup`を書き換え

```bin/setup.diff
diff --git a/bin/setup b/bin/setup
index 5853b5e..5f32464 100755
--- a/bin/setup
+++ b/bin/setup
@@ -15,10 +15,10 @@ FileUtils.chdir APP_ROOT do

  puts '== Installing dependencies =='
  system! 'gem install bundler --conservative'
-  system('bundle check') || system!('bundle install')
+  system('bundle check') || system!('bundle install --path=.bundle')

  # Install JavaScript dependencies
-  # system('bin/yarn')
+  system('bin/yarn')

  # puts "\n== Copying sample files =="
  # unless File.exist?('config/database.yml')
```

データベースつくって立ち上げて `http://localhost:3000` でアクセスできるか確認する

```console
% alias dc=docker-compose
% dc build
% dc run --rm app rails db:create
% dc run -e RAILS_ENV=test --rm app rails db:create
% dc run --rm app setup
% dc up
```

## 掲示板を作る

掲示板には名前がある

```console
% dc run --rm app rails g scaffold Board title:string
```

## 名前がNULLの掲示板をやめたい

生成されたマイグレーションを見ると`title` カラムがNULL可になっている
そのため名前がない掲示板が作れる。しかし名前は必須としたい

```db/migrate/20200606170056_create_boards.rb
class CreateBoards < ActiveRecord::Migration[6.0]
  def change
    create_table :boards do |t|
      t.string :title

      t.timestamps
    end
  end
end
```

NULL可を生まれる前に消し去るのがよさそう
NULLオプションを付け忘れても大丈夫なようにいつも `null: false` をつけるパッチを書きます

```config/initializers/always_prefer_not_null.rb
ActiveRecord::ConnectionAdapters::TableDefinition.prepend(Module.new {
  def column(name, type, index: nil, **options)
    super(name, type, index: index, **options.merge(null: false))
  end
})
```

NULLの考慮が必要なくなりました。便利。

でもマイグレーションにちゃんと `null: false` と書いておきたいので `null` に `false` を設定しておきます

```db/migrate/20200606170056_create_boards.rb
class CreateBoards < ActiveRecord::Migration[6.0]
  def change
    create_table :boards do |t|
      t.string :title, null: false

      t.timestamps
    end
  end
end
```

## 投稿を作る

掲示板には投稿があり以下の3つを持つものとします、なお投稿者はUserモデルを作るのが面倒なので文字列です
	どこの掲示板への投稿か
	投稿者
	本文

```console
% dc run --rm app rails g model Post board:references poster:string body:text
```

マイグレーションに `null: false` を追加

```db/migrate/20200607031441_create_posts.rb
class CreatePosts < ActiveRecord::Migration[6.0]
  def change
    create_table :posts do |t|
      t.references :board, null: false, foreign_key: true
      t.string :poster, null: false
      t.text :body, null: false

      t.timestamps
    end
  end
end
```

## バリデーションを追加しテストを書く

- Boardのtitleが空でないことを確認
- Boardに紐づく投稿はBoardを削除する際に合わせて削除する
- Postのposterが空でないことを確認
- Postのbodyが空でないことを確認

```app/models/board.rb
class Board < ApplicationRecord
  has_many :posts, dependent: :destroy

  validates :title, presence: true
end
```

```test/models/board_test.rb
require 'test_helper'

class BoardTest < ActiveSupport::TestCase
  test "title validation" do
    assert Board.new(title: 'board').valid?
    assert Board.new(title: '').invalid?
    assert Board.new(title: nil).invalid?
  end

  test "dependent destroy" do
    assert_difference('Post.count', -1) do
      boards(:one).destroy
    end
  end
end
```

```app/models/post.rb
class Post < ApplicationRecord
  belongs_to :board

  validates :poster, :body, presence: true
end
```

```test/models/post_test.rb
require 'test_helper'

class PostTest < ActiveSupport::TestCase
  setup do
    @board = Board.new(title: 'board')
  end

  test "poster validation" do
    assert Post.new(board: @board, poster: 'john.doe', body: '4423').valid?
    assert Post.new(board: @board, poster: '', body: '4423').invalid?
    assert Post.new(board: @board, poster: nil, body: '4423').invalid?
  end

  test "body validation" do
    assert Post.new(board: @board, poster: 'john.doe', body: '4423').valid?
    assert Post.new(board: @board, poster: 'john.doe', body: '').invalid?
    assert Post.new(board: @board, poster: 'john.doe', body: nil).invalid?
  end
end
```

## 掲示板に投稿できるようにする

routesを生やし

```config/routes.rb
Rails.application.routes.draw do
  root to: 'boards#index'
  resources :boards do
    resources :posts, only: :create
  end
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end
```

`PostsController` を作る

```app/controllers/posts_controller.rb
class PostsController < ApplicationController
  before_action :set_board

  # POST /boards/:board_id/posts
  # POST /boards/:board_id/posts.json
  def create
    @new_post = @board.posts.new(post_params)

    respond_to do |format|
      if @new_post.save
        format.html { redirect_to @board, notice: 'Post was successfully created.' }
        format.json { render :show, status: :created, location: @board }
      else
        format.html { render 'boards/show' }
        format.json { render json: @new_post.errors, status: :unprocessable_entity }
      end
    end
  end

  private

    def set_board
      @board = Board.find(params[:board_id])
    end

    def post_params
      params.require(:post).permit(:poster, :body)
    end
end
```

`BoardsController#show` を書き換え

```app/controllers/posts_controller.rb
class BoardsController < ApplicationController
  # GET /boards/1
  # GET /boards/1.json
  def show
    @new_post = Board.find(@board.id).posts.new
  end
```

いい感じに投稿を表示・作成できるようにviewを書き換える

```erb:app/views/boards/show.html.erb
<p id="notice"><%= notice %></p>

<p>
  <strong>Title:</strong>
  <%= @board.title %>
</p>
<%= render @board.posts %>
<%= render 'posts/form', board: @board, post: @new_post %>
<%= link_to 'Edit', edit_board_path(@board) %> |
<%= link_to 'Back', boards_path %>
```

```erb:app/views/posts/_post.html.erb
<p>
  <%= post.poster %>:<%= post.body %>
</p>
```

```erb:app/views/posts/_form.html.erb
<%= form_with(model: [board, post], local: true) do |form| %>
  <% if post.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2>

      <ul>
        <% post.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= form.label :poster %>
    <%= form.text_field :poster %>
  </div>

  <div class="field">
    <%= form.label :body %>
    <%= form.text_field :body %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>
```

## 投稿を削除したい: 真の削除の場合

`DELETE` でレコードを消す世界観、べんり

```config/routes.rb
Rails.application.routes.draw do
  root to: 'boards#index'
  resources :boards do
    resources :posts, only: %I[create destroy]
  end
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end
```

```app/controllers/posts_controller.rb
class PostsController < ApplicationController
  before_action :set_board
  before_action :set_post, only: :destroy

  # DELETE /boards/:board_id/posts/1
  # DELETE /boards/:board_id/posts/1.json
  def destroy
    @post.destroy
    respond_to do |format|
      format.html { redirect_to @board, notice: 'Post was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private
    def set_board
      @board = Board.find(params[:board_id])
    end

    def set_post
      @post = @board.posts.find(params[:id])
    end
end
```

```erb:app/views/posts/_post.html.erb
<p>
  <%= post.poster %>:<%= post.body %>
  <%= link_to 'Destroy', [post.board, post], method: :delete, data: { confirm: 'Are you sure?' } %> </p>
```

## 投稿を削除したい: 投稿内容を削除したい編

真の削除をすると投稿があったかどうかが分からない
真の削除をすると投稿自体がなかったことになる
たとえば掲示板への総投稿数を `@board.posts.count`した数値も減る
真の削除はせず投稿自体は残したまま投稿内容を「投稿は削除されました」としたい

投稿内容を上書きする場合はこう

```config/routes.rb
Rails.application.routes.draw do
  root to: 'boards#index'
  resources :boards do
    resources :posts, only: %I[create destroy update]
  end
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end
```

```app/controllers/posts_controller.rb
class PostsController < ApplicationController
  before_action :set_board
  before_action :set_post, only: %I[destroy update]
  
  # PATCH/PUT /boards/:board_id/posts/1
  # PATCH/PUT /boards/:board_id/posts/1.json
  def update
    respond_to do |format|
      if @post.update(update_post_params)
        format.html { redirect_to @board, notice: 'Post was successfully updated.' }
        format.json { render :show, status: :ok, location: @post }
      else
        format.html { render :edit }
        format.json { render json: @post.errors, status: :unprocessable_entity }
      end
    end
  end
 
  private

    def set_board
      @board = Board.find(params[:board_id])
    end

    def set_post
      @post = @board.posts.find(params[:id])
    end
    
    def update_post_params
      params.require(:post).permit(:body)
    end
end
```

```erb:app/views/posts/_post.html.erb
<p>
  <%= post.poster %>:<%= post.body %>
  <%= link_to 'Destroy', [post.board, post], method: :delete, data: { confirm: 'Are you sure?' } %>
  <%= link_to 'Update the body', [post.board, post], remote: true, method: :patch, data: { confirm: 'Are you sure?', params: { post: { body: 'The body is deleted.' } }.to_param } %>
</p>
```

## 投稿を削除したい: 元の投稿内容は保存しておき投稿内容を取り消したい編

投稿内容を上書きするとどういう投稿があったかが後から調べられない
真の削除はせず投稿自体はのこしたまま投稿を取り消し「投稿は削除されました」とするが投稿した内容自体は残したい

`cancel` を実装しましょう

```config/routes.rb
Rails.application.routes.draw do
  root to: 'boards#index'
  resources :boards do
    resources :posts, only: %I[create destroy update] do
      post :cancel, on: :member
    end
  end
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end
```

```app/controllers/posts_controller.rb
class PostsController < ApplicationController
  before_action :set_board
  before_action :set_post, only: %I[destroy update cancel]

  # POST /boards/:board_id/posts/1/cancel
  # POST /boards/:board_id/posts/1/cancel.json
  def cancel
    respond_to do |format|
      if @post.cancel
        format.html { redirect_to @board, notice: 'Post was successfully cancelled.' }
        format.json { render :show, status: :ok, location: @post }
      else
        format.html { render :edit }
        format.json { render json: @post.errors, status: :unprocessable_entity }
      end
    end
  end
end
```

```app/models/post.rb
class Post < ApplicationRecord
  def cancel(at: Time.zone.now)
    self.cancelled_at = at
    save
  end

  def cancelled?
    !cancelled_at.nil?
  end

  def display_body
    cancelled? ? 'This post is deleted.' : body
  end
end
```

```erb:app/views/posts/_post.html.erb
<p>
  <%= post.poster %>:<%= post.display_body %>
  <% unless post.cancelled? %>
    <%= link_to 'Destroy', [post.board, post], method: :delete, data: { confirm: 'Are you sure?' } %>
    <%= link_to 'Update the body', [post.board, post], remote: true, method: :patch, data: { confirm: 'Are you sure?', params: { post: { body: 'The body is deleted.' } }.to_param } %>
    <%= link_to 'Cancel', cancel_board_post_path(post.board, post), remote: true, method: :post, data: { confirm: 'Are you sure?' } %>
  <% end %>
</p>
```

## 投稿を削除したい: 元の投稿内容は保存しておき投稿内容は取り消したいがそれを更に取り消したい

投稿内容を上書きするとどういう投稿があったかが後から調べられない
真の削除はせず投稿自体はのこしたまま投稿を取り消し「投稿は削除されました」とするが投稿した内容自体は残したい
しかし間違えて取り消しちゃったのでやっぱり元に戻したい
そんなときがあると思います

`uncancel` を実装しましょう

```config/routes.rb
Rails.application.routes.draw do
  root to: 'boards#index'
  resources :boards do
    resources :posts, only: %I[create destroy update] do
      member do
        post :cancel
        post :uncancel
      end
    end
  end
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end
```

```app/controllers/posts_controller.rb
class PostsController < ApplicationController
  before_action :set_board
  before_action :set_post, only: %I[destroy update cancel uncancel]

  # POST /boards/:board_id/posts/1/uncancel
  # POST /boards/:board_id/posts/1/uncancel.json
  def uncancel
    respond_to do |format|
      if @post.uncancel
        format.html { redirect_to @board, notice: 'Post was successfully uncancelled.' }
        format.json { render :show, status: :ok, location: @post }
      else
        format.html { render :edit }
        format.json { render json: @post.errors, status: :unprocessable_entity }
      end
    end
  end
end
```

```app/models/post.rb
class Post < ApplicationRecord
  def uncancel
    self.cancelled_at = nil
    save
  end
end
```

```erb:app/views/posts/_post.html.erb
<p>
  <%= post.poster %>:<%= post.display_body %>
  <% if post.cancelled? %>
    <%= link_to 'Uncancel', uncancel_board_post_path(post.board, post), remote: true, method: :post, data: { confirm: 'Are you sure?' } %>
  <% else %>
    <%= link_to 'Destroy', [post.board, post], method: :delete, data: { confirm: 'Are you sure?' } %>
    <%= link_to 'Update the body', [post.board, post], remote: true, method: :patch, data: { confirm: 'Are you sure?', params: { post: { body: 'The body is deleted.' } }.to_param } %>
    <%= link_to 'Cancel', cancel_board_post_path(post.board, post), remote: true, method: :post, data: { confirm: 'Are you sure?' } %>
  <% end %>
</p>
```

## 投稿を削除したい: 元の投稿内容は保存しておき投稿内容は取り消したいがそれを更に取り消したい feat. NOT NULL

投稿内容を上書きするとどういう投稿があったかが後から調べられない
真の削除はせず投稿自体はのこしたまま投稿を取り消し「投稿は削除されました」とするが投稿した内容自体は残したい
しかし間違えて取り消しちゃったのでやっぱり元に戻したい

そういうときに `cancelled_at` カラムを追加して `cancel` メソッドを呼び出し投稿を取り消し `uncancel` メソッドで投稿の取り消しを更に取り消す実装をしました

しかし新しい投稿は取り消されているわけではないので `cancelled_at` には `nil` が入ります。NOT NULLの制約を掛けられません

`Post#cancel` を実装する代わりに `cancel` の動詞を名詞の `cancellation` に変更し投稿内容を取り消す際は `cancellation` リソースを作成することにします
こうすると `cancellation` のモデルを別に分けることができます
`cancellation` のモデルが作成された日時を投稿内容が取り消された日時としましょう
NOT NULLがつけられます、やったね

先程 `cancelled_at` を追加した際はカラムにNOT NULL制約がかかりませんでした
NULLオプションを付け忘れても大丈夫なようにいつも `null: false` をつけるパッチがカラム追加でも動作するようにします

```config/initializers/always_prefer_not_null.rb
# NOTE: PostgreSQLAdapter is not loaded before connecion resolved
require "active_record/connection_adapters/postgresql_adapter"
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(Module.new {
  def add_column(table_name, column_name, type, **options)
    super(table_name, column_name, type, **options.merge(null: false))
  end
})
```

`posts` テーブルから `cancelled_at` カラムを削除し `post_cancellations` テーブルを追加します

```db/migrate/20200607164016_remove_cancelled_at_from_posts.rb
class RemoveCancelledAtFromPosts < ActiveRecord::Migration[6.0]
  def change
    remove_column :posts, :cancelled_at
  end
end
```

```db/migrate/20200607164139_create_post_cancellations.rb
class CreatePostCancellations < ActiveRecord::Migration[6.0]
  def change
    create_table :post_cancellations do |t|
      t.references :post, null: false, foreign_key: true

      t.timestamps
    end
  end
end
```

`member` アクションが2つ消えて `cancellation` リソースが増えました

```config/routes.rb
Rails.application.routes.draw do
  root to: 'boards#index'
  resources :boards do
    resources :posts, only: %I[create destroy update] do
      resource :cancellation, only: %I[create destroy], controller: 'post_cancellations'
    end
  end
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end
```

`PostsController` の役割は減り

```app/controllers/posts_controller.rb
class PostsController < ApplicationController
  # cancel / uncancelを削除
  before_action :set_post, only: %I[destroy update]
end
```

新たに `PostCancellationsController` が投稿の取り消しを扱うようになりました

```app/controllers/post_cancellations_controller.rb
class PostCancellationsController < ApplicationController
  before_action :set_board
  before_action :set_post

  # POST /boards/:board_id/posts
  # POST /boards/:board_id/posts.json
  def create
    respond_to do |format|
      if @post.create_cancellation
        format.html { redirect_to @board, notice: 'PostCancellation was successfully created.' }
        format.json { render :show, status: :created, location: @board }
      else
        format.html { render 'boards/show' }
        format.json { render json: @post.cancellation.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /boards/:board_id/posts/1/cancellation
  # DELETE /boards/:board_id/posts/1/cancellation.json
  def destroy
    @post.cancellation.destroy
    respond_to do |format|
      format.html { redirect_to @board, notice: 'PostCancellation was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private

    def set_board
      @board = Board.find(params[:board_id])
    end

    def set_post
      @post = @board.posts.find(params[:post_id])
    end
end
```

投稿取り消しは投稿自体とは別のモデルで持つようになり

```app/models/post.rb
class Post < ApplicationRecord
  has_one :cancellation, class_name: 'PostCancellation', dependent: :destroy

  def cancelled?
    !cancellation.nil?
  end
end
```

```app/models/post_cancellation.rb
class PostCancellation < ApplicationRecord
  belongs_to :post, inverse_of: :cancellation
end
```

投稿取り消しリソースへのPOST/DELETEへとリンクが変更

```erb:app/views/posts/_post.html.erb
<p>
  <%= post.poster %>:<%= post.display_body %>
  <% if post.cancelled? %>
    <%= link_to 'Uncancel', board_post_cancellation_path(post.board, post), remote: true, method: :delete, data: { confirm: 'Are you sure?' } %>
  <% else %>
    <%= link_to 'Destroy', [post.board, post], method: :delete, data: { confirm: 'Are you sure?' } %>
    <%= link_to 'Update the body', [post.board, post], remote: true, method: :patch, data: { confirm: 'Are you sure?', params: { post: { body: 'The body is deleted.' } }.to_param } %>
    <%= link_to 'Cancel', board_post_cancellation_path(post.board, post), remote: true, method: :post, data: { confirm: 'Are you sure?' } %>
  <% end %>
</p>
```


## 投稿を削除したい: 元の投稿内容は保存しておき投稿内容は取り消したい関連した操作履歴を消したくない

投稿内容を上書きするとどういう投稿があったかが後から調べられない
真の削除はせず投稿自体はのこしたまま投稿を取り消し「投稿は削除されました」とするが投稿した内容自体は残したい
しかし間違えて取り消しちゃったのでやっぱり元に戻したい
しかし誰が投稿の取り消し・取り消しの取り消しをしたのか確認したい

`PostCancellation` と `Post` の間に中間モデル `PostPostCancellation` をおき有効な取り消し操作を示すようにします
`PostPostCancellation` を削除しても`PostCancellation` 自体は消えないようにしておきます
有効な取り消し履歴は1つのみとするためUNIQUE制約をかけます

```db/migrate/20200607170618_create_post_post_cancellations.rb
class CreatePostPostCancellations < ActiveRecord::Migration[6.0]
  def change
    create_table :post_post_cancellations do |t|
      t.references :post, null: false, foreign_key: true
      t.references :post_cancellation, null: false, foreign_key: true

      t.timestamps
    end
    add_index :post_post_cancellations, %I[post_id post_cancellation_id], name: 'index_post_post_cancellation_unique'
  end
end
```

いい感じにモデルの定義を更新して

```app/models/post.rb
class Post < ApplicationRecord
  has_one :post_post_cancellation, dependent: :destroy
  has_one :cancellation, class_name: 'PostCancellation', through: :post_post_cancellation, source: :post_cancellation
  has_many :post_cancellations, dependent: :destroy
end
```

```app/models/post_cancellation.rb
class PostCancellation < ApplicationRecord
  belongs_to :post
  has_one :post_post_cancellation, dependent: :destroy
end
```

```app/models/post_post_cancellation.rb
class PostPostCancellation < ApplicationRecord
  belongs_to :post
  belongs_to :post_cancellation
end
```

いい感じに  `@post.post_post_cancellation` を操作するようにします

```app/controllers/post_cancellations_controller.rb
class PostCancellationsController < ApplicationController
  before_action :set_board
  before_action :set_post

  # POST /boards/:board_id/posts
  # POST /boards/:board_id/posts.json
  def create
    respond_to do |format|
      if @post.create_post_post_cancellation(post_cancellation: @post.post_cancellations.build)
        format.html { redirect_to @board, notice: 'PostCancellation was successfully created.' }
        format.json { render :show, status: :created, location: @board }
      else
        format.html { render 'boards/show' }
        format.json { render json: @post.cancellation.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /boards/:board_id/posts/1/cancellation
  # DELETE /boards/:board_id/posts/1/cancellation.json
  def destroy
    @post.post_post_cancellation.destroy
    respond_to do |format|
      format.html { redirect_to @board, notice: 'PostCancellation was successfully destroyed.' }
      format.json { head :no_content }
    end
  end
end
```

ここの `destroy` で誰がどの `post_cancellation` に対する `post_post_cancellation` を削除したのか `post_cancellation_deletion` を作れば履歴が残る

## 投稿を削除したい: 元の投稿内容は保存しておき投稿内容は取り消したい関連した操作履歴を消したくない feat. SELECT, INSERT

投稿内容を上書きするとどういう投稿があったかが後から調べられない
真の削除はせず投稿自体はのこしたまま投稿を取り消し「投稿は削除されました」とするが投稿した内容自体は残したい
しかし間違えて取り消しちゃったのでやっぱり元に戻したい
しかし誰が投稿の取り消し・取り消しの取り消しをしたのか確認したい
そしてDELETEを絶対に発行したくない場合

`missing` を使うため `6-0-stable` を `master` に書き換え `bundle update` します

```Gemfile
# gem 'rails', github: "rails/rails", branch: "6-0-stable"
gem 'rails', github: "rails/rails", branch: "master"
```

`PostPostCancellation` を消して付け替えるのではなく `PostCancellationInvalidation` を作成しましょう

```db/migrate/20200608172902_drop_post_post_cancellations.rb
class DropPostPostCancellations < ActiveRecord::Migration[6.0]
  def change
    drop_table :post_post_cancellations
  end
end
```

```db/migrate/20200608173626_create_post_cancellation_invalidations.rb
class CreatePostCancellationInvalidations < ActiveRecord::Migration[6.0]
  def change
    create_table :post_cancellation_invalidations do |t|
      t.references :post_cancellation, null: false, foreign_key: true

      t.timestamps
    end
  end
end
```

```routes.rb
Rails.application.routes.draw do
  root to: 'boards#index'
  resources :boards do
    resources :posts, only: %I[create destroy update] do
      resource :cancellation, only: :create, controller: 'post_cancellations' do
        resource :invalidation, only: :create, controller: 'post_cancellation_invalidations'
      end
    end
  end
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end
```

```app/controllers/post_cancellation_invalidations_controller.rb
class PostCancellationInvalidationsController < ApplicationController
  before_action :set_board
  before_action :set_post
  before_action :set_cancellation

  # POST /boards/:board_id/posts/:post_id/cancellation/invalidation
  # POST /boards/:board_id/posts/:post_id/cancellation/invalidation.json
  def create
    respond_to do |format|
      if @cancellation.create_invalidation
        format.html { redirect_to @board, notice: 'PostCancellationInvalidation was successfully created.' }
        format.json { render :show, status: :created, location: @board }
      else
        format.html { render 'boards/show' }
        format.json { render json: @cancellation.invalidation.errors, status: :unprocessable_entity }
      end
    end
  end

  private

    def set_board
      @board = Board.find(params[:board_id])
    end

    def set_cancellation
      @cancellation = @post.cancellation
    end

    def set_post
      @post = @board.posts.find(params[:post_id])
    end
end
```

```app/models/post.rb
class Post < ApplicationRecord
  belongs_to :board
  has_one :cancellation, -> { where.missing(:invalidation) }, class_name: 'PostCancellation'
  has_many :post_cancellations, dependent: :destroy

  validates :poster, :body, presence: true

  def cancelled?
    !cancellation.nil?
  end

  def display_body
    cancelled? ? 'This post is deleted.' : body
  end
end
```

```app/models/post_cancellation.rb
class PostCancellation < ApplicationRecord
  belongs_to :post
  has_one :invalidation, class_name: 'PostCancellationInvalidation'
end
```

```app/models/post_cancellation_invalidation.rb
class PostCancellationInvalidation < ApplicationRecord
  belongs_to :post_cancellation
end
```

```erb:app/views/posts/_post.html.erb
<p>
  <%= post.poster %>:<%= post.display_body %>
  <% if post.cancelled? %>
    <%= link_to 'Uncancel', board_post_cancellation_invalidation_path(post.board, post), remote: true, method: :post, data: { confirm: 'Are you sure?' } %>
  <% else %>
    <%= link_to 'Destroy', [post.board, post], method: :delete, data: { confirm: 'Are you sure?' } %>
    <%= link_to 'Update the body', [post.board, post], remote: true, method: :patch, data: { confirm: 'Are you sure?', params: { post: { body: 'The body is deleted.' } }.to_param } %>
    <%= link_to 'Cancel', board_post_cancellation_path(post.board, post), remote: true, method: :post, data: { confirm: 'Are you sure?' } %>
  <% end %>
</p>
```

絶対に削除できないようにActiveRecordのモデルを削除・更新できない用のユーザーを作っておく

```db/migrate/20200610173406_create_rails_app_role.rb
class CreateRailsAppRole < ActiveRecord::Migration[6.1]
  def up
    execute(<<~SQL)
      revoke all on all tables in schema public from rails_app;
      revoke all on all sequences in schema public from rails_app;
      revoke all on database kesenai_tsumi_development from rails_app;
      drop role rails_app;
      create role rails_app login password 'kesenai';
      grant connect on database kesenai_tsumi_development to rails_app;
      grant select, insert on all tables in schema public to rails_app;
      grant update on all sequences in schema public to rails_app;
    SQL
  end

  def down
    execute(<<~SQL)
      revoke all on all tables in schema public from rails_app;
      revoke all on all sequences in schema public from rails_app;
      revoke all on database kesenai_tsumi_development from rails_app;
      drop role rails_app;
    SQL
  end
end
```

普段Railsアプリを実行する際は削除する権限がないユーザーを使って接続し、マイグレーションを行うときはマイグレーションを行う権限があるユーザーで行う

```docker-compose.yml
version: "3.8"

# 一部略
services:
  app: &rails
    environment:
      DATABASE_URL: postgres://rails_app:kesenai@db:5432
  migration:
    <<: *rails
    environment:
      DATABASE_URL: postgres://postgres:hi@db:5432
```

## まとめ

以下のような形で投稿内容の削除を実装してみました

- 真の削除
- 投稿内容を削除
- 元の投稿内容は保存しておき投稿内容を取り消し
- 元の投稿内容は保存しておき投稿内容は取り消したいがそれを更に取り消したい
- 元の投稿内容は保存しておき投稿内容は取り消したいがそれを更に取り消したい feat. NOT NULL
- 元の投稿内容は保存しておき投稿内容は取り消したい関連した操作履歴を消したくない
- 元の投稿内容は保存しておき投稿内容は取り消したい関連した操作履歴を消したくない feat. SELECT, INSERT

いかがでしたか?

### 真の削除
- postリソースのみ扱う
- ActiveRecordのdestroyメソッドを使うだけなので単純
- 削除された投稿内容は確認できない
- 誰が投稿したかも確認できない
- 削除をなかったことにできない
- 削除に関連する操作の履歴はわからない

### 投稿内容を削除
- postリソースのみ扱う
- ActiveRecordのupdateメソッドを使うだけなので単純
- 削除された投稿内容は確認できない
- 誰が投稿したかは確認できる
- 投稿があったことはわかる
- 削除をなかったことにできない
- 削除に関連する操作の履歴はわからない

### 元の投稿内容は保存しておき投稿内容を取り消し
- postリソースのみ扱う
- postリソースに対して独自のmemberアクションcancelを1つ追加
- Postモデルにnull可なカラムが増えPostがキャンセルされたかどうかの状態を持つようになった
- 削除された投稿内容を確認できる
- 誰が投稿したかはわかる
- 投稿があったことはわかる
- 削除をなかったことにできない
- 削除に関連する操作の履歴はわからない

### 元の投稿内容は保存しておき投稿内容は取り消したいがそれを更に取り消したい
- postリソースのみ扱う
- postリソースに対して独自のmemberアクションcancel/uncancelの2つを追加
- Postモデルにnull可なカラムが増えPostがキャンセルされたかどうかの状態を持つようになった
- 削除された投稿内容を確認できる
- 誰が投稿したかはわかる
- 投稿があったことはわかる
- 削除をなかったことにできる
- 削除に関連する操作の履歴はわからない

### 元の投稿内容は保存しておき投稿内容は取り消したいがそれを更に取り消したい feat. NOT NULL
- postリソースに加え子リソースのcancellationを扱う
- cancellationでは標準のアクションcreate/destroyの2つを実装
- Postモデルにnull可能なカラムはないのでPostが変化する状態を持たなくなった
- PostCancellationモデルが増えた
- 削除された投稿内容を確認できる
- 誰が投稿したかはわかる
- 投稿があったことはわかる
- 削除をなかったことにできる
- 削除に関連する操作の履歴はわからない

### 元の投稿内容は保存しておき投稿内容は取り消したい関連した操作履歴を消したくない
- postリソースに加え子リソースのcancellationを扱う
- cancellationでは標準のアクションcreate/destroyの2つを実装
- Postモデルにnull可能なカラムはないのでPostが変化する状態を持たなくなった
- PostCancellationモデルが増えた
- PostPostCancellationモデルも増えた
- 削除された投稿内容を確認できる
- 誰が投稿したかはわかる
- 投稿があったことはわかる
- 削除をなかったことにできる
- 削除に関連する操作の履歴がわかる

### 投稿を削除したい: 元の投稿内容は保存しておき投稿内容は取り消したい関連した操作履歴を消したくない feat. SELECT, INSERT
- postリソースに加え子リソースのcancellationを扱う
- cancellationでは標準のアクションcreate/destroyの2つを実装
- Postモデルにnull可能なカラムはないのでPostが変化する状態を持たなくなった
- PostCancellationモデルが増えた
- PostPostCancellationモデルも増えた
- 削除された投稿内容を確認できる
- 誰が投稿したかはわかる
- 投稿があったことはわかる
- 削除をなかったことにできる
- 削除に関連する操作の履歴がわかる
- DELETEせず常にINSERT

## 所感

- 単純に削除できるならそれが一番簡単
- 何を削除するのか考えたい
- モデルの状態を操作すると複雑さが増す
  - NULL / NOT NULL
  - Rails標準ではないアクションの定義
- 削除する操作に名前を付けリソースとして扱うと削除対象のモデルに状態をもたせたり削除対象のモデルを操作しなくて済み削除する操作自体のリソースを残すことができ、便利な場面がある。削除対象が不変なら削除を取り消すのも簡単にできる。一方実装は膨らみがち。

## おわりに

要はバランス (完)