掲示板を作る
誰しもが
一度は作る
掲示板
@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
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
#!/bin/bash
set -e
rm -f /app/tmp/pids/server.pid
exec "$@"
そして bin/setup
を書き換え
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可になっている
そのため名前がない掲示板が作れる。しかし名前は必須としたい
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
をつけるパッチを書きます
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
を設定しておきます
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
を追加
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が空でないことを確認
class Board < ApplicationRecord
has_many :posts, dependent: :destroy
validates :title, presence: true
end
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
class Post < ApplicationRecord
belongs_to :board
validates :poster, :body, presence: true
end
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を生やし
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
を作る
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
を書き換え
class BoardsController < ApplicationController
# GET /boards/1
# GET /boards/1.json
def show
@new_post = Board.find(@board.id).posts.new
end
いい感じに投稿を表示・作成できるようにviewを書き換える
<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 %>
<p>
<%= post.poster %>:<%= post.body %>
</p>
<%= 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
でレコードを消す世界観、べんり
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
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
<p>
<%= post.poster %>:<%= post.body %>
<%= link_to 'Destroy', [post.board, post], method: :delete, data: { confirm: 'Are you sure?' } %> </p>
投稿を削除したい: 投稿内容を削除したい編
真の削除をすると投稿があったかどうかが分からない
真の削除をすると投稿自体がなかったことになる
たとえば掲示板への総投稿数を @board.posts.count
した数値も減る
真の削除はせず投稿自体は残したまま投稿内容を「投稿は削除されました」としたい
投稿内容を上書きする場合はこう
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
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
<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
を実装しましょう
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
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
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
<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
を実装しましょう
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
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
class Post < ApplicationRecord
def uncancel
self.cancelled_at = nil
save
end
end
<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
をつけるパッチがカラム追加でも動作するようにします
# 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
テーブルを追加します
class RemoveCancelledAtFromPosts < ActiveRecord::Migration[6.0]
def change
remove_column :posts, :cancelled_at
end
end
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
リソースが増えました
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
の役割は減り
class PostsController < ApplicationController
# cancel / uncancelを削除
before_action :set_post, only: %I[destroy update]
end
新たに PostCancellationsController
が投稿の取り消しを扱うようになりました
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
投稿取り消しは投稿自体とは別のモデルで持つようになり
class Post < ApplicationRecord
has_one :cancellation, class_name: 'PostCancellation', dependent: :destroy
def cancelled?
!cancellation.nil?
end
end
class PostCancellation < ApplicationRecord
belongs_to :post, inverse_of: :cancellation
end
投稿取り消しリソースへのPOST/DELETEへとリンクが変更
<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制約をかけます
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
いい感じにモデルの定義を更新して
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
class PostCancellation < ApplicationRecord
belongs_to :post
has_one :post_post_cancellation, dependent: :destroy
end
class PostPostCancellation < ApplicationRecord
belongs_to :post
belongs_to :post_cancellation
end
いい感じに @post.post_post_cancellation
を操作するようにします
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
します
# gem 'rails', github: "rails/rails", branch: "6-0-stable"
gem 'rails', github: "rails/rails", branch: "master"
PostPostCancellation
を消して付け替えるのではなく PostCancellationInvalidation
を作成しましょう
class DropPostPostCancellations < ActiveRecord::Migration[6.0]
def change
drop_table :post_post_cancellations
end
end
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
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
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
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
class PostCancellation < ApplicationRecord
belongs_to :post
has_one :invalidation, class_name: 'PostCancellationInvalidation'
end
class PostCancellationInvalidation < ApplicationRecord
belongs_to :post_cancellation
end
<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のモデルを削除・更新できない用のユーザーを作っておく
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アプリを実行する際は削除する権限がないユーザーを使って接続し、マイグレーションを行うときはマイグレーションを行う権限があるユーザーで行う
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標準ではないアクションの定義
- 削除する操作に名前を付けリソースとして扱うと削除対象のモデルに状態をもたせたり削除対象のモデルを操作しなくて済み削除する操作自体のリソースを残すことができ、便利な場面がある。削除対象が不変なら削除を取り消すのも簡単にできる。一方実装は膨らみがち。
おわりに
要はバランス (完)