2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

削除しない掲示板

Posted at

掲示板を作る

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

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

Railsアプリを作る

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

% 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

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 でアクセスできるか確認する

% 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

掲示板を作る

掲示板には名前がある

% 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 と書いておきたいので nullfalse を設定しておきます

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モデルを作るのが面倒なので文字列です
どこの掲示板への投稿か
投稿者
本文

% 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を書き換える

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 %>
app/views/posts/_post.html.erb
<p>
  <%= post.poster %>:<%= post.body %>
</p>
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
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
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
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
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へとリンクが変更

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>

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

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

PostCancellationPost の間に中間モデル 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-stablemaster に書き換え bundle update します

# 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
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標準ではないアクションの定義
  • 削除する操作に名前を付けリソースとして扱うと削除対象のモデルに状態をもたせたり削除対象のモデルを操作しなくて済み削除する操作自体のリソースを残すことができ、便利な場面がある。削除対象が不変なら削除を取り消すのも簡単にできる。一方実装は膨らみがち。

おわりに

要はバランス (完)

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?