Override Primary key ID trong Rails

Rails được xây dựng trên nguyên tắc Convention over Configuration nghĩa là gần như lập trình viên đã được giảm thiểu tối ta việc tuân thủ convention khi phát triển, thay vào đó bản thân Framework đã làm thay việc đó. Nó bao gồm cả việc cài đặt primary key cho 1 bảng trong database luôn là cột ID. Tuy nhiên nếu chúng ta muốn sử dụng một cột khác làm primary key thì thế nào ? Trong bài hôm nay mình sẽ hướng dẫn các bạn cách để làm điều đó. Giả sử mình có 1 bảng là Product, mỗi record có 1 chuối ID đặc biệt, mình tạm gọi là sku, chuỗi ID này là unique và thay vì truy vấn theo id như thường lệ thì database sẽ truy vấn dự theo chuỗi này. Mình tạo 1 scaffold như sau

$ rails g scaffold products sku

What is primary key?

Đầu tiên chúng ta sẽ cùng tìm hiểu 1 primary key là gì ? Một cột được gọi là một primary key nếu:

  • Tồn tại ràng buộc not NULL
  • Tồn tại ràng buộc UNIQUE
  • Được đánh INDEX Chúng ta có thể chứng minh điều đó bằng cách kiểm tra trong DB:
$ rails db
psql (9.3.4)
Type "help" for help.

webstote_development=# \d products
                                     Table "public.products"
   Column   |            Type             |                       Modifiers
------------+-----------------------------+-------------------------------------------------------
 id         | integer                     | not null default nextval('products_id_seq'::regclass)
 sku        | character varying(255)      |
 created_at | timestamp without time zone |
 updated_at | timestamp without time zone |
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)

Migration

Bây giờ chúng ta cùng xem qua file migration

class CreateProducts < ActiveRecord::Migration
  def change
    create_table :products do |t|
      t.string :sku

      t.timestamps
    end
  end
end

Theo Convention thì chúng ta k thể thấy bất cứ thông tin gì về cột id primary key, cột id này sẽ được tự động tạo khi tiến hành tạo bảng. Tuy nhiên chúng ta có thể nói cho Rails hiểu không tạo cột id bằng cách thêm đoạn options id: false như sau:

  ...
  create_table :products, id: false do |t|
    ...
  end
  ...

Vây làm thế nào để chỉ cho Rails biết chúng ta chỉ định cột sku là 1 primary key. Sau khi tìm hiểu thì mình tìm được hàm ActiveRecord::ConnectionAdapters::TableDefinitiion#primary_key với code như sau:

# File activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb, line 68
def primary_key(name, type = :primary_key, options = {})
  column(name, type, options.merge(:primary_key => true))
end

Và mình đã sử dụng nó trong file migration như sau:

create_table :products, id: false do |t|
  t.primary_key :sku

  t.timestamps
end

Sau khi chạy migrate, mình kiểm tra lại DB của mình, kết quả như sau:

                                  Table "public.products"
   Column   |            Type             |                       Modifiers
------------+-----------------------------+--------------------------------------------------------
 sku        | integer                     | not null default nextval('products_sku_seq'::regclass)
 created_at | timestamp without time zone |
 updated_at | timestamp without time zone |
Indexes:
"products_pkey" PRIMARY KEY, btree (sku)

Tuy nhiên cột sku đang có datatype là integer, trong khi mình mong muốn nó là 1 string, vì theo convention thì primary key phải là kiểu integer. Mình đã thử các cách sau:

create_table :products, id: false do |t|
  t.string :sku, primary: true

  t.timestamps
end

hoặc

create_table :products, id: false do |t|
  t.string :sku, primary_key: true

  t.timestamps
end

nhưng nó vẫn không hoạt động. Bây giờ chúng ta cùng xem lại, như mình nói từ đầu, 1 primary key phải thỏa các điều kiện, là not NULL, UNIQUE, và được đánh INDEX, ok fine, và đây là cách của mình.

class CreateProducts < ActiveRecord::Migration
  def change
    create_table :products, id: false do |t|
      t.string :sku, null: false

      t.timestamps
    end

    add_index :products, :sku, unique: true
  end
end

kiểm tra lại database thử xem nhé

           Table "public.products"
   Column   |            Type             | Modifiers
------------+-----------------------------+-----------
 sku        | character varying(255)      | not null
 created_at | timestamp without time zone |
 updated_at | timestamp without time zone |
Indexes:
"index_products_on_sku" UNIQUE, btree (sku)

Mặc dù cột sku không phải là primary key constraint, nhưng nó tương đương như một primary key vậy. Chúng ta sẽ chỉ rõ cho model Product hiểu sku là primary_key.

class Product < ActiveRecord::Base
  self.primary_key = 'sku'
end

Routing

Mặc định của Rails sẽ tạo ra các URL với reference là :id như sau:

bundle exec rake routes
            Prefix Verb  URI Pattern                       Controller#Action
           products GET   /products(.:format)                 products#index
                   POST   /products(.:format)                 products#create
        new_product GET   /products/new(.:format)             products#new
       edit_product GET   /products/:id/edit(.:format)        products#edit
            product GET   /products/:id(.:format)             products#show
                  PATCH   /products/:id(.:format)             products#update
                    PUT   /products/:id(.:format)             products#update
                 DELETE   /products/:id(.:format)             products#destroy

Chúng ta có thể thay đổi nó như sau:

# config/routes.rb
resources :products, param: :sku

Và kiểm tra lại lần nữa

bundle exec rake routes
            Prefix Verb  URI Pattern                       Controller#Action
           products GET   /products(.:format)              products#index
                   POST   /products(.:format)              products#create
        new_product GET   /products/new(.:format)          products#new
       edit_product GET   /products/:sku/edit(.:format)    products#edit
            product GET   /products/:sku(.:format)         products#show
                  PATCH   /products/:sku(.:format)         products#update
                    PUT   /products/:sku(.:format)         products#update
                 DELETE   /products/:sku(.:format)         products#destroy

Cần chú ý răng, chúng ta phải thay đổi params trong ProductsController để tìm kiếm theo params[:sku] thay vì params[:id]

# app/controllers/products_controller.rb
def set_product
  @product = Product.find(params[:sku])
end

URI

Một vấn đề nữa, giả như chúng ta có một mã sku điên điên như 'SKU 001', khi chúng ta acess qua ProductsController#show, chúng ta sẽ có đường dẫn như sau:

http://0.0.0.0:3000/products/SKU%23001

Xem URI có vẻ không thân thiện cho lắm, cho nên mình sẽ override lại method #to_param như sau:

class Product < ActiveRecord::Base
  ...
  def to_param
    sku.parameterize
  end
  ...
end

và URI lúc này sẽ trả về:

http://0.0.0.0:3000/products/SKU-001

Kết luận

Như mình đã nói từ đầu, Rails được xây dựng trên nguyên tắc Convention over Configuration, vì vậy, chỉ trong trường hợp bắt buộc nào đó mới thực hiện việc override primary key, vì nó đang vi phạm convention của Rails. Chúc các bạn ăn tết vui vẻ 😄