--- title: Astro課程 0723-0731 - Rails (Day1-Day4) tags: astro, rails --- # 一起來做BBS看板! ## Rails專案實作快速連結 [0723 Day1 建立看板](https://hackmd.io/uSW5LI6aS1GyCsEMtGBnCg?view#0724-%E7%AC%AC%E4%BA%8C%E5%A4%A9) [0724 Day2 第一個Model: 新增看板](https://hackmd.io/uSW5LI6aS1GyCsEMtGBnCg?view#0724-%E7%AC%AC%E4%BA%8C%E5%A4%A9) [0730 Day3 第二個Model: 新增文章](https://hackmd.io/uSW5LI6aS1GyCsEMtGBnCg?view#%E7%AC%AC%E4%B8%89%E5%A4%A9) [0731 Day4 資料驗證、第三個Model: 新增使用者](https://hackmd.io/uSW5LI6aS1GyCsEMtGBnCg?view#0731-%E7%AC%AC%E5%9B%9B%E5%A4%A9) ## 提問時間 <200> Q:網頁200 202 300 404......等是什麼意思? A:[HTTP狀態碼](https://developer.mozilla.org/zh-TW/docs/Web/HTTP/Status) [Ref:傳紙條的故事](https://lidemy.com/courses/net101-js/lectures/9655079) < Type > Q:Type是什麼意思? A: ex:`'Content-Type' => 'text/html'`告訴瀏覽器將這個網頁以html來看待 # 專案主題發想 V PTT forum / comment / message / mail - 專案管理系統 Jira / Trello - 記帳 - Forum / Reddit - 購物網站 - Email https://hey.com/how-it-works/ - Flowdock / Slack / Line / Tinder (ActionCable - WebSocket) - Podcast 平台 - POS系統 (iPad) - https://www.ichefpos.com/zh-tw/delivery-integration - https://sharelike.asia/ - TimeTree RWD - https://timetreeapp.com/ - GitHub 目前比較不適合的專案 - 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 ``` 2. rails g controller ``` pages_controller.rb # 檔名是蛇式 class PagesController # 類別名稱是駝峰式 ``` 3. 在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 ``` 3. 在view印出所有看板 ``` <h1>看板</h1> <%= @boards %> ``` ``` 看板 #<Board::ActiveRecord_Relation:0x00007fc91ded30f8> ``` 4. 用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"> ``` 5. 在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, `method`用`post`,不會像`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? 設定 ![](https://i.imgur.com/8VVhibu.png) # 0724 第二天 ## form 可以長表單出來 有3種 1. `form_for`後面要接model `form_for(model)` ``` <%= form_for(Board.new) do |f| %> <% end %> ``` 2. `form_tag` 不需要model eg. 輸入身高體重、計算BMI(不需要用到資料庫) 3. 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](https://sass-lang.com/guide) 修改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 = ? ``` ## `find` 和 `find_by`的差別 1. `find` 只能找`id` `find_by`可以接hash `Board.find_by(id: param[:id], intro: "aaa")` 2. 找不到時 `.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 ``` 2. `_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](https://rubygems.org/gems/paranoia) 複製最新版的套件 ``` 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 - 2. 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`幫我們作出了`create`和`build`方法 ``` 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 ``` [官網](https://rails.ruby.tw/routing.html) 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-有什麼不同](https://medium.com/@anneju/rails%E5%9C%A8%E5%BB%BA%E7%AB%8B%E8%A1%A8%E5%96%AE%E7%9A%84%E6%99%82%E5%80%99-form-for-%E8%B7%9F-form-with-%E6%9C%89%E4%BB%80%E9%BA%BC%E4%B8%8D%E5%90%8C-ec45cebbbf92) form_with是用Ajax方式送資料,所以畫面上不會有動作 [送資料的方式:turbolinks](https://www.writershelf.com/article/rails-turbolinks%E2%84%A2-5-%E6%B7%B1%E5%BA%A6%E7%A0%94%E7%A9%B6?locale=zh-TW) 寫法的差異 ``` <%= 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](https://guides.rubyonrails.org/association_basics.html#options-for-has-one-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 ``` 排列組合的機率,保證序號重複可能性很低! ![](https://i.imgur.com/ITFUHXb.png) ## 把驗證序號加入Post Model [Rails Guide - 2.11 uniqueness](https://guides.rubyonrails.org/active_record_validations.html#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回呼流程](https://railsbook.tw/chapters/19-model-validation-and-callback.html) rails model的存檔流程 ![](https://i.imgur.com/Vj9hF1M.png) 先用鴕鳥方式處理舊文章:`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](https://ruby-doc.org/stdlib-2.5.1/libdoc/digest/rdoc/Digest.html) 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 ```