目前 Post 只有 title 跟 body
一篇貼文還需要網址用的 slug、摘要、發布狀態跟發布時間
我們一個一個加上去, 順便看每個選項在做什麼
網址會用到不能重複的 slug:
attribute :slug, :string do
allow_nil? false
public? true
end
identities do
identity :unique_slug, [:slug]
end
allow_nil? false 代表必填, create 跟 update 都會檢查identities 定義哪些欄位組合起來不能重複, 在 Postgres 上會建成 unique indexunique_slug 這個名字之後查詢跟 upsert 也會用到
attribute :excerpt, :string do
public? true
constraints max_length: 300
end
constraints 是跟著型別走的限制
字串可以設 max_length、min_length、match、trim?、allow_empty?
轉換 input 的時候就會檢查
attribute :status, :atom do
allow_nil? false
public? true
default :draft
constraints one_of: [:draft, :review, :published, :archived]
end
狀態只有固定幾種, 用 :atom 加上 one_of 最簡單, 存進資料庫時是文字
沒給 status 的話會用 default :draft
所以就算設了 allow_nil? false, create 時不傳 status 也沒問題
attribute :published_at, :utc_datetime do
public? true
end
:utc_datetime 精確到秒, timestamps() 用的 :utc_datetime_usec 精確到微秒
目前還沒有地方會寫入 published_at, 之後交給 action 處理
四個新欄位都加了 public? true
Day 3 提過 attribute 預設是 privatepublic? 決定外面看不看得到、能不能碰這個欄位
但一個 action 實際收哪些欄位, 要看它的 accept
先讓預設的 create 跟 update 收 published_at 以外的欄位:
actions do
defaults [
:read,
:destroy,
create: [:title, :slug, :body, :excerpt, :status],
update: [:title, :slug, :body, :excerpt, :status]
]
end
published_at 是 public, 可是沒有任何 action 收它
等等在 iex 裡會看到差別
改完 resource, 一樣用 ash.codegen 產生 migration:
$ mix ash.codegen add_publishing_attributes
* creating priv/repo/migrations/20260916172819_add_publishing_attributes.exs
* creating priv/resource_snapshots/repo/posts/20260916172820.json
def up do
alter table(:posts) do
add :slug, :text, null: false
add :excerpt, :text
add :status, :text, null: false, default: "draft"
add :published_at, :utc_datetime
end
create unique_index(:posts, [:slug], name: "posts_unique_slug_index")
end
對照一下:
allow_nil? false 變成 null: false
default :draft 變成 default: "draft"
max_length 跟 one_of 不會變成資料庫的限制, 由 Ash 自己檢查
slug 不能是 null, 可是 Day 2 建的貼文都沒有 slug
直接 mix ash.migrate 會失敗
dev 環境的資料直接重置就好:
mix ash.reset
正式環境就得先在 migration 裡補上回填 slug 的 SQL, 再跑 migrate
iex> alias DemoCms.Content.Post
iex> {:ok, post} = Ash.create(Post, %{title: "Hello, Ash!", slug: "hello-ash", body: "First post."})
iex> {post.slug, post.status, post.published_at, post.excerpt}
{"hello-ash", :draft, nil, nil}
status 沒有傳, 用的是預設值
每條規則都試一次:
iex> Ash.create!(Post, %{title: "No slug"})
** (Ash.Error.Invalid)
* attribute slug is required
iex> Ash.create!(Post, %{title: "Again", slug: "hello-ash"})
** (Ash.Error.Invalid)
* Invalid value provided for slug: has already been taken.
iex> Ash.create!(Post, %{title: "x", slug: "x", status: :live})
** (Ash.Error.Invalid)
* Invalid value provided for status: atom must be one of "draft, review, published, archived", got: :live.
iex> Ash.create!(Post, %{title: "x", slug: "x", excerpt: String.duplicate("x", 301)})
** (Ash.Error.Invalid)
* Invalid value provided for excerpt: length must be less than or equal to 300.
slug 重複是 Postgres 的 unique index 擋下來的
Ash 把資料庫的錯誤轉回 slug 欄位上
其他三個在送進資料庫前就擋掉了
input 會先照欄位型別轉換, 所以 atom 欄位傳字串也行:
iex> Ash.create!(Post, %{title: "y", slug: "y", status: "review"}).status
:review
再來看 public 但沒有被 accept 的欄位:
iex> Ash.create!(Post, %{title: "z", slug: "z", published_at: "2026-09-01T09:00:00Z"})
** (Ash.Error.Invalid)
* No such input `published_at` for action DemoCms.Content.Post.create
public? 管的是讀不讀得到、能不能回傳出去accept 管的是這個 action 收不收published_at 只符合前者
最後把 resource 的定義跟實際的資料表放在一起看:
iex> Ash.Resource.Info.attributes(Post) |> Enum.map(&{&1.name, &1.type, &1.allow_nil?, &1.default})
[
{:id, Ash.Type.UUID, false, &Ash.UUID.generate/0},
{:title, Ash.Type.String, false, nil},
{:slug, Ash.Type.String, false, nil},
{:body, Ash.Type.String, true, nil},
{:excerpt, Ash.Type.String, true, nil},
{:status, Ash.Type.Atom, false, :draft},
{:published_at, Ash.Type.UtcDatetime, true, nil},
{:inserted_at, Ash.Type.UtcDatetimeUsec, false, &DateTime.utc_now/0},
{:updated_at, Ash.Type.UtcDatetimeUsec, false, &DateTime.utc_now/0}
]
iex> DemoCms.Repo.query!("select column_name, data_type, is_nullable, column_default from information_schema.columns where table_name = 'posts'").rows
[
["id", "uuid", "NO", "gen_random_uuid()"],
["title", "text", "NO", nil],
["body", "text", "YES", nil],
["inserted_at", "timestamp without time zone", "NO", "(now() AT TIME ZONE 'utc'::text)"],
["updated_at", "timestamp without time zone", "NO", "(now() AT TIME ZONE 'utc'::text)"],
["slug", "text", "NO", nil],
["excerpt", "text", "YES", nil],
["status", "text", "NO", "'draft'::text"],
["published_at", "timestamp without time zone", "YES", nil]
]
欄位都準備好了, 但現在誰都能透過預設 action 直接改 status
下一篇把預設的 create 跟 update 換成有名字的 action