Try   HackMD

一起來做BBS看板!

Rails專案實作快速連結

0723 Day1 建立看板
0724 Day2 第一個Model: 新增看板
0730 Day3 第二個Model: 新增文章
0731 Day4 資料驗證、第三個Model: 新增使用者

提問時間

<200>
Q:網頁200 202 300 404等是什麼意思?
A:HTTP狀態碼

Ref:傳紙條的故事

< Type >
Q:Type是什麼意思?
A: ex:'Content-Type' => 'text/html'告訴瀏覽器將這個網頁以html來看待

專案主題發想

V PTT forum / comment / message / mail

目前比較不適合的專案

  • Netflix 可以做的部分:前端、切版
  • HackMD md -> html,即時互動
  • Blog Medium
  • 口罩地圖 / 等公車 / 前端
  • 購物網站
  • Game RPG Maker / Unity (C++)
  • Facebook

PTT功能

  • 公佈欄
  • 我的最愛
  • 分類
  • 信件
  • 聊天
  • 個人設定 UserInfo

新增首頁

  1. 改routes
Rails.application.routes.draw do
  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html

  root "pages#index" 
  get '/about', to: "pages#about" 

end
  1. rails g controller
pages_controller.rb  # 檔名是蛇式
class PagesController # 類別名稱是駝峰式

  1. 在pages下新增view檔案

Board看板

Board的資料欄位

- title:string
- intro:text

- deleted_at:datetime, nil/null (知道何時被刪除) 
    -> 加index增加搜尋速度, 但會減慢寫入速度

(- is_deleted:boolean 是否被刪除, 預設false, 可以用deleted_at取代)

- state: [normal, disable, hidden]

SQL: 列出所有(沒被刪除)的看板

select * 
from boards
where deleted_at is not null

建立model

rails g model Board title:string intro:text deleted_at:datetime:index state:string

以上指令會建出 app/models/board.rb

board.rb  # 檔名是蛇式
class Board # 類別名稱是駝峰式

在migrate的create_boards.rb
把state設為normal

      t.string :state, default: 'normal'

rails db:migrate

== 20200723061253 CreateBoards:
migrating =====================================
-- create_table(:boards)
   -> 0.0012s
-- add_index(:boards, :deleted_at)
   -> 0.0008s
== 20200723061253 CreateBoards: migrated (0.0021s) ============================

rails db指令可以進去sqlite查詢資料表

rails db        



SQLite version 3.28.0 2019-04-15 14:49:49
Enter ".help" for usage hints.

sqlite> select * from schema_migrations;
20200723061253

Q. 如果執行migration後要再修改欄位:

  • 多人協作
    rails g migration [name]> rename_column
    重新執行rails db:migration
  • 一個人做
    rails db:rollback

Q. 沒有存檔,欄位還沒更新就執行rails db:migrate,怎麼抓錯誤?
-> 查看schema.rb,看看欄位有沒有被更新過

console模式新增資料

rails c查看目前建立的Board

 /PPT   master  rails c 
Running via Spring preloader in process 1560
Loading development environment (Rails 5.2.4.3)
2.5.2 :001 > Board.all
  Board Load (2.6ms)  SELECT  "boards".* FROM "boards" LIMIT ?  [["LIMIT", 11]]
 => #<ActiveRecord::Relation []> 

進入--sandbox模式假裝創建資料

 /PPT   master  rails c --sandbox
Running via Spring preloader in process 1614
Loading development environment in sandbox (Rails 5.2.4.3)
Any modifications you make will be rolled back on exit
2.5.2 :001 > Board.create(title: 'Ruby')

   (0.1ms)  SAVEPOINT active_record_1
  Board Create (1.6ms)  INSERT INTO "boards" ("title", "created_at", "updated_at") VALUES (?, ?, ?)  [["title", "Ruby"], ["created_at", "2020-07-23 06:30:02.969252"], ["updated_at", "2020-07-23 06:30:02.969252"]]
   (0.1ms)  RELEASE SAVEPOINT active_record_1
 => #<Board id: 1, title: "Ruby", intro: nil, deleted_at: nil, state: "normal", created_at: "2020-07-23 06:30:02", updated_at: "2020-07-23 06:30:02"> 

追查類別的方法定義在哪一行 .source_location

2.5.2 :002 > Board
 => Board(id: integer, title: string, intro: text, deleted_at: datetime, state: string, created_at: datetime, updated_at: datetime) 

2.5.2 :003 > Board.method(:all)
 => #<Method: Board(id: integer, title: string, intro: text, deleted_at: datetime, state: string, created_at: datetime, updated_at: datetime).all>
 
2.5.2 :004 > Board.method(:all).source_location
 => ["/Users/tingtinghsu/.rvm/gems/ruby-2.5.2/gems/activerecord-5.2.4.3/lib/active_record/scoping/named.rb", 26] 

2.5.2 :005 > 3.days.method(:ago).source_location
 => ["/Users/tingtinghsu/.rvm/gems/ruby-2.5.2/gems/activesupport-5.2.4.3/lib/active_support/duration.rb", 366] 

列出boards看板列表

RESTful Routes

[wiki](Representational State Transfer)
用同個標準命名路徑
視路徑為resources

Eg. 看板可能的路徑path:

/boards
/boards/2
/boards/2/edit

  1. rails g controller boards
  2. 在controller新增methods
class BoardsController < ApplicationController
  def index
    @boards = Board.all #用實體變數裝起來傳給view
  end
end
  1. 在view印出所有看板
<h1>看板</h1>

<%= @boards %>
看板
#<Board::ActiveRecord_Relation:0x00007fc91ded30f8>
  1. 用console c新增資料
2.5.2 :005 > Board.create(title: 'Ruby')
   (0.1ms)  begin transaction
  Board Create (1.7ms)  INSERT INTO "boards" ("title", "created_at", "updated_at") VALUES (?, ?, ?)  [["title", "Ruby"], ["created_at", "2020-07-23 07:45:47.861782"], ["updated_at", "2020-07-23 07:45:47.861782"]]
   (1.3ms)  commit transaction
 => #<Board id: 1, title: "Ruby", intro: nil, deleted_at: nil, state: "normal", created_at: "2020-07-23 07:45:47", updated_at: "2020-07-23 07:45:47"> 


2.5.2 :006 > Board.create(title: 'PHP')
   (0.4ms)  begin transaction
  Board Create (1.5ms)  INSERT INTO "boards" ("title", "created_at", "updated_at") VALUES (?, ?, ?)  [["title", "PHP"], ["created_at", "2020-07-23 07:46:35.574927"], ["updated_at", "2020-07-23 07:46:35.574927"]]
   (1.4ms)  commit transaction
 => #<Board id: 2, title: "PHP", intro: nil, deleted_at: nil, state: "normal", created_at: "2020-07-23 07:46:35", updated_at: "2020-07-23 07:46:35"> 
  1. 在view用.each印出所有的檔案
<h1>看板</h1>
<ul>
  <% @boards.each do |b| %>
    <li><%= b.title %></li>
  <% end %>
</ul>

網頁顯示

看板

Ruby
PHP

新增看板

建立連結

在view建立連結

  <a href="/boards/new">新增看板</a>

改成name_path

  1. 確保開發時路徑不會打錯
  2. 路徑名稱更改時,連結自動對應

Q:主管說名稱從"boards"改為"cards"該怎麼做?
A:進到routes.rb檔執行以下

  resources :boards, path: 'cards'
  <a href="<%= new_board_path %>">新增看板</a>

rails的寫法link_to

  <%= link_to '新增看板', new_board_path if false %>

新增看板

new.html.erb,
methodpost,不會像get把參數(如密碼)直接放在網址上

<h1>新增看板</h1>

<form action="/boards" method="post">
  <label for="title">看板名稱</label>
  <input type="text" name="title" id="title"><br>

  <label for="intro">說明</label>
  <textarea name="intro" id="intro"></textarea>
  <input type="submit" value="送出">
</form>

在controller印出來傳進來的參數"create"看看

  def create
    render html: params
  end

出現InvalidAuthenticityToken錯誤

ActionController::InvalidAuthenticityToken in BoardsController#create

如果沒有認證的Token,就沒有辦法寫入,資安上比較安全

params是從某一頁表單傳到另一列的參數
一個修正後的hash
params["title"]
params[:title]都可以拿到資料

先用爛方法在controller用render把答案印出來

  def create
    render html: params

  end

頁面會顯示
http://localhost:3000/boards

{"authenticity_token"=>"KxKoyhFrzt59vnHbxTIRRtfX4eYzfRez5I7cY78DRN86H1nTdh+7joUBogmEIUPBf8HrnSWbMKOjeq7FAWU80A==", "title"=>"123", "intro"=>"456", "controller"=>"boards", "action"=>"create"}

只抓出重要欄位並存入

  def create
    Board.create(title: params[:title], intro: params[:intro])
    redirect_to "/"
  end

如何讓erb顯示出html的emmet?

設定

0724 第二天

form

可以長表單出來
有3種

  1. form_for後面要接model

form_for(model)

<%= form_for(Board.new) do |f| %>

<% end %>
  1. form_tag 不需要model

eg. 輸入身高體重、計算BMI(不需要用到資料庫)

  1. form_for 二合一
  • form_with(model:, url: )
  • form_with(url: )

form_for

form_for(Board.new)
會猜是要新增,決定路徑往哪裡去
(有的時候會猜錯,所以會在後面加上URL)

<%= form_for(ApplicationRecord.new) do |f| %>

<% end %>

=> 會出錯,抽象類別不能new出實體

利用form_for建立欄位

<%= form_for(Board.new) do |f| %>
  <%= f.label(:title), "看板名稱" %>
  <%= f.text_field(:title)%>
<% end %>

和昨天做的form有麼不同?

檢查網頁原始碼,原本的欄位

  <label for="title">看板名稱</label>
  <input type="text" id="title" name="title"><br />

  <label for="intro">說明</label>
  <textarea id="intro" name="intro"></textarea>
Parameters: {"authenticity_token"=>"35/KxPh1Xkg==", "title"=>"456", "intro"=>"789"}

利用form_for做出來

  <label for="board_title">看板名稱</label>
  <input type="text" name="board[title]" id="board_title" />

  <label for="board_intro">看板說明</label>
  <textarea name="board[intro]" id="board_intro">

form_for根據model的名字欄位的名字會包成一包hash

Parameters: {"utf8"=>"✓", "authenticity_token"=>"YnNwIUkCkvm=", 
"board"=>{"title"=>"123", "intro"=>"456"}, "commit"=>"送出"}

改寫: 實體變數應該要放在controller

controller

  def new
    @board = Board.new
  end

view

<%= form_for(@board) do |f| %>
  <%= f.label :title, "看板名稱" %>
  <%= f.text_field :title %>

  <%= f.label :intro, "看板說明" %>
  <%= f.text_area :intro %>
  <%= f.submit "送出" %>
<% end %>

create method

 def new
    @board = Board.new 
    #要先new實體變數不然表單的引數會是nil
  end
  
  def create
    # http沒有狀態,每次request都是全新的開始
    # 兩次request 會產生兩個不一樣的實體
    # 所以這裡的@board和new方法的@board是不同的
    
    @board = Board.new(params[:board])
  end

params的作用?

params是一個方法,負責把傳過來的字串打包好成hash

#想像params方法定義在上層,作用看起來像這樣子:
  def params
    {:board => "aa"}
  end
  def create
    @board = Board.new(params[:board])

    if @board.save
      # OK
    else
      # NG
    end


    # Board.create(title: params[:title], intro: params[:intro])
    # redirect_to "/"
  end

在index上用param的話,只能在console裡看到,頁面上不會顯示

  def index
      puts "---------------------"
      p params
      p params[:title]
      puts "---------------------"
      @boards = Board.all

    @boards = Board.all
  end
<ActionController::Parameters 
{"controller"=>"boards", "action"=>"index"}
permitted: false>
nil

新增看板成功寫入時,回到首頁

  def create
    @board = Board.new(params[:board])

    if @board.save
      redirect_to boards_path
    else
      # NG
    end

strong parameters

strong parameters處理controller的 Forbidden Attributes Error問題

防止有心人士透過開發者工具塞參數進來

  def create
    # Strong Parameters
    
    clean_params = params.require(:board).permit(:title, :intro)

    @board = Board.new(clean_params)

    if @board.save
      redirect_to boards_path
    else
      # NG
    end
  end

改寫成private方法

  private
  
  # Strong Parameters
  
  def board_params
    params.require(:board).permit(:title, :intro)
  end
  def create
    @board = Board.new(board_params)
  end

思考controller

controller幾乎不能重新使用,但是model可以
每個controller的method只能做一件事情

  def index
    @boards = Board.all
    render html: "hi"
    redirect_to root
  end

錯誤訊息: DoubleRenderError

AbstractController::DoubleRenderError in BoardsController#index

controller的method預設會去尋找同名的view檔案,但一個method只能執行一個動作
eg. 如果又加了render -> 畫面顯示404頁面

  def index
    @boards = Board.all
    render file: "/public/404.html"    
    # redirect_to '/'
  end

flash[:notice]

controller: 新增成功時出現訊息

    if @board.save
      flash[:notice] = "新增成功!"
      redirect_to boards_path
    else
      # NG
    end

view

<h2><%= flash[:notice] %></h2>

or

<h2><%= notice %></h2>

或是放在公板application.html.erb

flash太常使用了,所以也可以直接在controller提示:

    if @board.save
      redirect_to boards_path, notice: "新增成功"
    else
      # NG
    end

寫入失敗的條件

model的驗證條件

class Board < ApplicationRecord
  validates :title, :intro,  presence: true, length: {minimum: 2}
  
  # 舊式寫法
  # validates_presence_of :title 
end
 ~/Documents/projects/PPT   master ●  rails c --sandbox
Running via Spring preloader in process 6455
Loading development environment in sandbox (Rails 5.2.4.3)
Any modifications you make will be rolled back on exit

2.5.2 :001 > b1 = Board.new
 => #<Board id: nil, title: nil, intro: nil, deleted_at: nil, state: "normal", created_at: nil, updated_at: nil> 

2.5.2 :002 > b1.errors.any?
 => false 

2.5.2 :003 > b1.save
   (0.2ms)  SAVEPOINT active_record_1
   (0.2ms)  ROLLBACK TO SAVEPOINT active_record_1
 => false 

2.5.2 :004 > b1.errors.any?
 => true 
 
2.5.2 :006 > b1.errors.full_messages
 => ["Title can't be blank", "Title is too short (minimum is 2 characters)", "Intro can't be blank", "Intro is too short (minimum is 2 characters)"] 

欄位出錯的時候,form_for會在錯誤欄位包一層東西,
如何用css呈現錯誤的欄位?
SASS

修改css

form {
  label {
    display: block;
  }


  .field_with_errors {
    input,textarea {
      border: 2px solid red;
    }
  }
}

把錯誤的原因印出來

在view跑迴圈
重要:不要在view裡面new出實體!

<% if @board.errors.any? %>
  <ul>
  <% @board.errors.full_messages.each do |message| %>
  <li><%= message %></li>
  <% end %>
  </ul>
<% end %>

建立各個看板的頁面

  • index頁面跑迴圈加上超連結
<ul>
  <% @boards.each do |board| %>
    <li><%= link_to board.title, board_path(board) %></li>
  <% end %>
</ul>
  • 建立show.html.erb

  • controller加上方法

試著把抓的param在show method印出來看看

  def show
    puts "-"* 50
    p params
    puts "-"* 50  
  end

點入任一個版面的連結
server會抓到參數

Started GET "/boards/1" for 127.0.0.1 at 2020-07-24 15:23:20 +0800
Processing by BoardsController#show as HTML
  Parameters: {"id"=>"1"}
--------------------------------------------------
<ActionController::Parameters {"controller"=>"boards", "action"=>"show", "id"=>"1"} permitted: false>
--------------------------------------------------

透過param[:id]可以抓到數字

透過參數撈資料的方法

  def show
    Board.where(id: params[:id]) => [1, 1]
    Board.find_by(id: params[:id]) # => 1
    Board.find(params[:id]) # 同id有多筆時會出錯
    
    # 以上3種都會翻譯成SQL
    # select * from boards where id = ?

findfind_by的差別

find 只能找id
find_by可以接hash
Board.find_by(id: param[:id], intro: "aaa")

  1. 找不到時

.find_by 回傳nil

2.5.2 :008 > Board.find_by(id: 1234)
  Board Load (0.5ms)  SELECT  "boards".* FROM "boards" WHERE "boards"."id" = ? LIMIT ?  [["id", 1234], ["LIMIT", 1]]
 => nil 

.find直接噴錯誤訊息

2.5.2 :011 > Board.find(1234)
  Board Load (0.3ms)  SELECT  "boards".* FROM "boards" WHERE "boards"."id" = ? LIMIT ?  [["id", 1234], ["LIMIT", 1]]
  
Traceback (most recent call last):
        1: from (irb):11
ActiveRecord::RecordNotFound (Couldn't find Board with 'id'=1234)

直接寫rescue

  def show
    begin
      @board = Board.find(params[:id])     
    rescue
      render file: '/public/404.html', status: 404
    end
  end

讓controller method裡面更乾淨的方法:

所有controller只要找不到就回傳404

class ApplicationController < ActionController::Base
  rescue_from ActiveRecord::RecordNotFound, with: :not_found

  private
  def not_found
    render file: '/public/404.html', status: 404
  end
end

讓Font-end Controller 和 Back-end Controller分開

Update

view上建立新增連結

  <% @boards.each do |board| %>
    <li>
      <%= link_to board.title, board_path(board) %>
       <%= link_to '編輯', edit_board_path(board) %> 
    </li>
  <% end %>

controller跟新增方法的流程類似

  def update
    @board = Board.find(params[:id])

    if @board.update(board_params)
      redirect_to boards_path, notice: "更新成功"
    else
      render :edit
    end
  end

Delete

delete是特殊的動詞,使用時必須標注 method: 'delete' (預設method: 'get')
重要:加上data: {confirm: '確認刪除?'}確認刪除

  <% @boards.each do |board| %>
    <li>
      <%= link_to board.title, board_path(board) %>
       <%= link_to '編輯', edit_board_path(board) %> 
       <%= link_to '刪除', board_path(board), method: 'delete',
        data: {confirm: '確認刪除?'} %>        
    </li>
  <% end %>

controller新增destroy方法

  def destroy
    @board = Board.find(params[:id])
    @board.destroy
    redirect_to boards_path, notice: "刪除成功"
  end

調整成假的刪除

改成update

  def destroy
    @board = Board.find(params[:id])
    #@board.destroy
    @board.update(deleted_at: Time.now)
    redirect_to boards_path, notice: "刪除成功"
  end

列出沒有被刪掉的看板

  def index
    @boards = Board.where(deleted_at: nil)
  end

自己寫一個destroy把它蓋掉

board.rb

class Board < ApplicationRecord
  validates :title, :intro,  presence: true, length: {minimum: 2}
  # validates_presence_of :title 

  def destroy
    update(deleted_at: Time.now)
  end
end

整理重複的程式碼

  1. 在某幾個action做事情
before_action :find_board, only: [:show, :edit. :updat, :destroy]
  private
  def find_board
    @board = Board.find(params[:id])
  end
  1. _form.html.erb 引入渲染的檔案

渲染的檔案盡量不要有區域變數
讓其他的地方帶實體變數進來,重複使用的好處

_form.html.erb

<% if board.errors.any? %>
  <ul>
  <% board.errors.full_messages.each do |message| %>
  <li><%= message %></li>
  <% end %>
  </ul>
<% end %>

<%= form_for(board) do |f| %>
  <%= f.label :title, "看板名稱" %>
  <%= f.text_field :title %>

  <%= f.label :intro, "看板說明" %>
  <%= f.text_area :intro %>
  <%= f.submit "送出" %>
<% end %>

new, show

<%= render 'form', board: @board %>

第三天

讓所有搜尋結果加上條件而不是只有Board.all

Eg. 找product價錢大於50元

Product.where(price > 50)
=> select * from products where price > 50

可以寫成 Product.cheap 或 Product.expensive
賦予這段程式碼意義

class Product 
    def self.expensive
        where("price > 100")
    end
end

什麼時候用類別方法? 什麼時候用實體方法?

Product.expensive => 使用類別方法

p = Product.new
p.save  => 使用實體方法

類別方法

找出存在的看板

def self.avaliable
  where(deleted_at: nil)
end

scope的寫法

一行程式碼可以解決的話就用scope

回呼函數 callback function
前面不要忘記加逗號,

scope :available, -> { where(deleted_at: nil) }

預設default_scope

作為預設的過濾條件

  default_scope { where(deleted_at: nil) }

抵銷預設scope
[Rails API] (https://github.com/rubysherpas/paranoia)
Board.unscoped

使用paranoia套件

實作假刪除功能

用法

Rubygem 複製最新版的套件

gem 'paranoia', '~> 2.4', '>= 2.4.2'

路徑:Gemfile/# Reduces boot times through caching; required in config/boot.rb

安裝套件的兩種方式

  1. Gemfile <- gem_name $ bundle(install)
    第一個方法會去更新Gemfile.lock檔,所以速度比較慢
  2. $ install gem_name

新的model: Post 文章

  • title:string 必填
  • content: text
  • board_id: integer 必填
    • board:references
    • board:belongs_to
      • 1.作用:產生board_id
        1. belongs_to board
  • state: string [draft/published/hidden]
  • deleted_at: datetime:nil:index
  • ip: string 必填
  • serial: string:unique
$ rails g model Post title content:text board:belongs_to deleted_at:datetime:index ip_address serial:string:unique

做錯了的話把rails g 改成rails d,就會刪掉原本建立的model

Post model

建立多對多關聯
一個看板有很多文章

has_many

board.rb : has_many :posts

class Board < ApplicationRecord
  acts_as_paranoid

  has_many :posts

  validates :title, presence: true, length: { minimum: 2 }
end

進去rails c來看

2.5.2 :005 > b = Board.find(2)
  Board Load (0.4ms)  SELECT  "boards".* FROM "boards" WHERE "boards"."deleted_at" IS NULL AND "boards"."deleted_at" IS NULL AND "boards"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
 => #<Board id: 2, title: "PHP", intro: nil, deleted_at: nil, state: "normal", created_at: "2020-07-23 07:46:35", updated_at: "2020-07-23 07:46:35"> 
 
2.5.2 :007 > b.posts
  Post Load (0.2ms)  SELECT  "posts".* FROM "posts" WHERE "posts"."board_id" = ? LIMIT ?  [["board_id", 2], ["LIMIT", 11]]
 => #<ActiveRecord::Associations::CollectionProxy []> 

如果剛開始建立的欄位不是board_id,而是b_id,要自己手動加上foreign_key

  has_many :posts, foreign_key: b_id

看板內建立新post

belongs_to

class Post < ApplicationRecord
  belongs_to :board, optional: true
end

如果沒有指定optional: true就一定要board_id才能存入

有很多種方法:

[找到後指定id]
px = Post.new(title: "aa")
px.board_id = 9
px.save

---

[跟上面的方法類似]
Post.create(title: "aa", board_id: 9)


---
[比較安全:先找到看板再新增文章然後存進去]
bb = Board.find(9)
px = Post.new(title: "aa")
px.board = bb 
px.save

---

[從belongs_to]的源頭往下寫
bb = Board.find(9)
bb.posts.create(title: "aa")

方法一 從post指定board_id

2.5.2 :001 > p = Post.new
 => #<Post id: nil, title: nil, content: nil, board_id: nil, deleted_at: nil, ip_address: nil, serial: nil, created_at: nil, updated_at: nil> 
 
 
2.5.2 :003 > p.board_id = 2
 => 2 
2.5.2 :004 > p.save
   (0.2ms)  begin transaction
  Board Load (0.6ms)  SELECT  "boards".* FROM "boards" WHERE "boards"."deleted_at" IS NULL AND "boards"."deleted_at" IS NULL AND "boards"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  Post Create (2.9ms)  INSERT INTO "posts" ("board_id", "created_at", "updated_at") VALUES (?, ?, ?)  [["board_id", 2], ["created_at", "2020-07-30 05:58:46.270668"], ["updated_at", "2020-07-30 05:58:46.270668"]]
   (1.7ms)  commit transaction
 => true 

方法二: 從board對應

因為belongs_to幫忙post產生了兩個方法

class Post < ApplicationRecord
  belongs_to :board
  # .board 
  # .board=[]
end

2.5.2 :006 > b = Board.second
  Board Load (8.8ms)  SELECT  "boards".* FROM "boards" WHERE "boards"."deleted_at" IS NULL AND "boards"."deleted_at" IS NULL ORDER BY "boards"."id" ASC LIMIT ? OFFSET ?  [["LIMIT", 1], ["OFFSET", 1]]
 => #<Board id: 3, title: "Python", intro: "123", deleted_at: nil, state: "normal", created_at: "2020-07-23 09:11:39", updated_at: "2020-07-24 08:31:13"> 

2.5.2 :007 > p = Post.new
 => #<Post id: nil, title: nil, content: nil, board_id: nil, deleted_at: nil, ip_address: nil, serial: nil, created_at: nil, updated_at: nil> 

2.5.2 :008 > p.board = b
 => #<Board id: 3, title: "Python", intro: "123", deleted_at: nil, state: "normal", created_at: "2020-07-23 09:11:39", updated_at: "2020-07-24 08:31:13"> 
 
2.5.2 :009 > p.save
   (1.2ms)  begin transaction
  Post Create (1.9ms)  INSERT INTO "posts" ("board_id", "created_at", "updated_at") VALUES (?, ?, ?)  [["board_id", 3], ["created_at", "2020-07-30 06:05:30.718942"], ["updated_at", "2020-07-30 06:05:30.718942"]]
   (1.2ms)  commit transaction
 => true 
 
2.5.2 :011 > b.posts.count
   (0.5ms)  SELECT COUNT(*) FROM "posts" WHERE "posts"."board_id" = ?  [["board_id", 3]]
 => 1 

方法三: 直接從看板的角度建立一篇文章

因為has_many幫我們作出了createbuild方法

b.posts.create(title: "aa")
2.5.2 :012 > b.posts.create(title: "從board的方式 create post")
   (0.2ms)  begin transaction
   
  Post Create (1.2ms)  INSERT INTO "posts" ("title", "board_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["title", "從board的方式 create post"], ["board_id", 3], ["created_at", "2020-07-30 06:10:30.054054"], ["updated_at", "2020-07-30 06:10:30.054054"]]
   (1.2ms)  commit transaction

=> #<Post id: 3, title: "從board的方式 create post", content: nil, board_id: 3, deleted_at: nil, ip_address: nil, serial: nil, created_at: "2020-07-30 06:10:30", updated_at: "2020-07-30 06:10:30"> 

has_one和 has_many的差別?

兩種方法長得有點不一樣

Has Many 
bb.posts.create  ...
bb.posts.build  / save

Has One
bb.create_post
bb.build_post

Eg.

class Board < ApplicationRecord
  validates :title, :intro,  presence: true, length: {minimum: 2}
  acts_as_paranoid

  has_many :posts
  has_one :post

end

has_one的SQL語法只找一筆


2.5.2 :001 > b = Board.find 2
  Board Load (0.5ms)  SELECT  "boards".* FROM "boards" WHERE "boards"."deleted_at" IS NULL AND "boards"."deleted_at" IS NULL AND "boards"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
 => #<Board id: 2, title: "PHP", intro: nil, deleted_at: nil, state: "normal", created_at: "2020-07-23 07:46:35", updated_at: "2020-07-23 07:46:35"> 

2.5.2 :002 > b.posts
  Post Load (2.0ms)  SELECT  "posts".* FROM "posts" WHERE "posts"."board_id" = ? LIMIT ?  [["board_id", 2], ["LIMIT", 11]]
 => #<ActiveRecord::Associations::CollectionProxy [#<Post id: 1, title: nil, content: nil, board_id: 2, deleted_at: nil, ip_address: nil, serial: nil, created_at: "2020-07-30 05:58:46", updated_at: "2020-07-30 05:58:46">]> 

2.5.2 :003 > b.post
  Post Load (0.2ms)  SELECT  "posts".* FROM "posts" WHERE "posts"."board_id" = ? LIMIT ?  [["board_id", 2], ["LIMIT", 1]]
 => #<Post id: 1, title: nil, content: nil, board_id: 2, deleted_at: nil, ip_address: nil, serial: nil, created_at: "2020-07-30 05:58:46", updated_at: "2020-07-30 05:58:46"> 

從頁面上新增文章

不好的網址設計:

Rails.application.routes.draw do
  resources :boards do
    resources :posts
  end
end

進階的寫法:把文章包進看板裡,把不需要的功能抽掉

Rails.application.routes.draw do

  resources :boards do
    resources :posts, only: [:index, :new, :create]
  end

  resources :posts, except: [:index, :new, :create]
end

官網
shallow (淺層嵌套)

Rails.application.routes.draw do

  resources :boards do
    resources :posts, shallow: true
end

練習

  • post controller
  • new頁面
  • form_for
<%= form_with(model: @post) do |f| %>
  <%= f.label :title, "文章名稱" %>
  <%= f.text_field :title %>

  <%= f.label :content, "文章內容" %>
  <%= f.text_area :content %>
  <%= f.submit "送出" %>
<% end %>

form_with 要塞model給它, 然後指定url(不然猜不到)

new 完之後送到create,路徑是board_posts_path

<%= form_with(model: @post, url: board_posts_path) do |form| %>

<%= form.submit %>

form_for 和 form_with差異

rails在建立表單的時候-form-for-跟-form-with-有什麼不同
form_with是用Ajax方式送資料,所以畫面上不會有動作

送資料的方式:turbolinks

寫法的差異

<%= form_with(model: @post, url: board_posts_path) do |form| %>

<%= form_for(@post, url: board_posts_path) do |form| %>

0731 第四天 資料驗證

補充昨天說明:rails scafford在form_with也是先設定 local: true (先逃避turbolinks的問題)

刪除看板會發生什麼事?

回想資料庫的資料的相依性(PK, FK)
資料的association: dependent

Eg. 如果設定 dependent: :destroy

  has_many :posts, dependent: :destroy
  

SQL會先幫我們砍看板,然後跟著砍掉文章

但是因為我們已經裝了paranoid假刪除功能
刪了等於沒刪 (dependent: :destroy這句無效)

class Board < ApplicationRecord
  acts_as_paranoid

  has_many :posts, dependent: :destroy

end

幫文章設計序號

問題:如何產生8位數亂碼?

def serial_generator(n)
  # a-z / A-Z / 0-9 產生亂數 
end

puts serial_generator(8)

思考

先把三組陣列蒐集起來

list = [*'A'..'Z'] + [*'a'..'z'] + [*'1'..'9']

解法

解法一. 把陣列利用sample取出八個數字,再join成字串

list.sample(8).join

解法二. 如果不知道sample這個method,想像洗牌的方式,
shuffle洗牌再取出前8張

list.shuffle.first(8).join

問題:8位數亂碼扣掉容易混淆的字元?

list = [*'A'..'Z'] + [*'a'..'z'] + [*'1'..'9']
pool = list - ['i','I', 'l', 'L', '1', '0', 'o', 'O']
pool.shuffle.first(8).join

排列組合的機率,保證序號重複可能性很低!

把驗證序號加入Post Model

Rails Guide - 2.11 uniqueness

class Post < ApplicationRecord
  validates :title, presence: true, length: {minimum: 2}
  validates :serial, presence: true, uniqueness: true
  belongs_to :board
end

uniqueness也可以加上scope

官方手冊的例子

  • 每一年都有一次聖誕節(名稱會重複) scope: :year
  • 但是同一年的節日名稱不會重複 validates :name, uniqueness
class Holiday < ApplicationRecord
  validates :name, uniqueness: { scope: :year,
    message: "should happen once per year" }
end

撰寫Post Model 裡的method

注意:不要寫在controller裡

Callback回呼流程

rails model的存檔流程

先用鴕鳥方式處理舊文章:allow_nil: true

class Post < ApplicationRecord
  validates :title, presence: true, length: {minimum: 2}

# 資料庫已經有很多文章是nil,非uniqueness
  validates :serial, presence: true, uniqueness: true, allow_nil: true
  belongs_to :board

  before_create :create_serial #存檔前先建立序號

  private
  def create_serial
    self.serial = serial_generator(10)
    # attr_reader的方法,省略小括弧
    # self.serial=(serial_generator(10))
  end

  def serial_generator(n)
    [*'a'..'z', *'A'..'Z', *0..9].sample(n).join
  end
end

Rake

寫一個rake來更新還沒有序號的舊文章

結構

namespace :db do
  desc "更新文章序號"
  task :update_post_serial do
    # puts "hi"
  end
end

Rake -T 指令列出所有的task

~/Documents/projects/PPT   master ●  rake -T        
rake about                              # List 

rake db:structure:dump                  # Dumps the database structure to db/...
rake db:structure:load                  # Recreates the databases from the st...
rake db:update_post_serial              # 更新文章序號

把剛剛寫的亂數產生序號方法塞進去
挑出序號為nil的文章,update post的序號

namespace :db do
  desc "更新文章序號"
  task :update_post_serial do
    Post.where(serial: nil).each do |post|
      post.update(serial: serial_generator(10))
    end
  end

  private
  def serial_generator(n)
    [*'a'..'z', *'A'..'Z', *0..9].sample(n).join
  end
end

執行Rake

task :update_post_serial => :environment
指定環境變數,不然會找不到Post

namespace :db do
  desc "更新文章序號"
  task :update_post_serial => :environment do
    puts "-----------------"
    puts " updating serial "
    puts "-----------------"
    Post.where(serial: nil).each do |post|
      post.update(serial: serial_generator(10))
    print "."
    end
    puts "done!"
  end

  private
  def serial_generator(n)
    [*'a'..'z', *'A'..'Z', *0..9].sample(n).join
  end
end

印出提示字元,讓使用者知道已經做完

 ~/Documents/projects/PPT   master ●  rails db:update_post_serial
-----------------
 updating serial 
-----------------
......done!

注意:呼叫方法時要注意receiver是誰
(例如private method的話就不能明確指出receiver)

如果self沒寫出來(隱含的self),跟寫出來self的意義不一樣

  task :update_post_serial => :environment do
    Post.where(serial: nil).each do |post|
      post.update(serial: serial_generator(10))
    end
    puts "done!"
  end

Ruby Self 和 JS this的差別

(待補充)

Ruby查找檔案的過程

Cat -> Animal -> Object -> BasicObject
檔案查找到Basic還找不到的時候會再回來Cat問有沒有method_missing
如果有的話,印出來
找不到的話,呼叫super上層來做

module Kernal
    def method_missing(...)
    # 錯誤訊息印在這裡
    end
end

這就是Ruby效能比較不好的原因

# 效能差
Board.find_by_id_title(1, "aaa") 

# 直接用hash找,效能好
Board.find_by(id: 1, title: "aaa")

User 使用者登入登出功能

model: User
- accout:string
- password:string
- email:string
- nickname:string
- gender:string
- state:string
- deleted_at:datetime:index


model: Profile
- 簽名檔
- 名片檔

Counter Cache: 用在上站次數。每次登入的時候先存入,增加效能

建Model之前,先了解資料庫的正規化

讓每個資料表只存該存的資料就好

第一正規化 (1NF)

每個欄位應該只有一筆資料 => 拆開來
刪除資料中的重複群組

至少要做到第一正規化,不然就失去了使用資料庫的意義

第二正規化 (2NF)

  1. 需要滿足 1NF
  2. 去除「部份相依性(Partial Dependency)」

Eg.三個model

  • student model (一個學生有很多技能)
  • skill model (每個技能都有很多學生使用)
  • skillset model (把相依性拆出來)

第三正規化 (3NF)

  1. 需要滿足 2NF
  2. 移除「遞移相依」(Transitive Dependency) / 間接相依
  • skills model 只留teacher id,不需要teacher的其他資訊
  • 拆出 teacher model

rails g model

rails g model User account:string:uniq password email:string:uniq nickname gender state deleted_at:datetime:index

然後rails db:migrate

user model 加上驗證

密碼加密
md5 -> 不安全
SHA-1
SHA-256

Digest::SHA1

rails c會載入常用的標準函式庫

require只會載入一次
irb

 ~/Documents/projects/PPT   master ●  irb
2.5.2 :001 > require 'digest'
 => true 
2.5.2 :002 > require 'digest'
 => false 
2.5.2 :003 > require 'digest'
 => false 

撰寫加密method

  # keyword argument 關鍵字引數
  def self.login(options)
    if options[:account] && options[:password]
      find_by(account: options[:account],              # 帳號: aa
             password: add_salt(options[:password]))   # 密碼: 已經加密為xyz
    # else
    #   return false
    end
  end
  
  
    private
  def encrypt_password
    # 註冊時加密
    self.password = User.add_salt(self.password) 
  end

  def self.add_salt(password)
    Digest::SHA1.hexdigest("x#{password}y")
  end

console c

2.5.2 :001 > User.login(account: "cc", password: "123")
  User Load (0.9ms)  SELECT  "users".* FROM "users" WHERE "users"."account" = ? AND "users"."password" = ? LIMIT ?  [["account", "cc"], ["password", "9eb8e9439adfea0f1615e2dc268117a2a32cb3bf"], ["LIMIT", 1]]
 => nil 

user route

單數和複數會長出不一樣的路徑

resources :users
/users/2/edit #=> 不應該給使用者看到id

resource :users
/user/edit   #=> 比較好的寫法
  resources :users, only: [:new, :create] do # 只有新增時看到自己序號, index user列表是給後台管理用的
    member do
      get :profile_member
    end

    collection do  # 擴充網址時,collection沒有id
      get :profile_collection
    end
  end

做出來路徑的差別

      profile_member_user GET    /users/:id/profile_member(.:format)                                                      users#profile_member
 profile_collection_users GET    /users/profile_collection(.:format)                                                      users#profile_collection

logout動詞用delete

  resources :users, only: [:new, :create] do
    collection do
      get :profile
      get :login
      delete :logout
    end
  end

跳脫CRUD制式的網址設計

不想用原本的八條路徑,更進階的寫法

Eg.把原本new的路徑改成sign_up,英文比較符合一般使用者註冊時的流程

  resources :users, only: [:create] do
    collection do
      get :sign_up
      get :edit
      patch :update
      get :sign_in
      post :login
      delete :sign_out
    end
  end