improvement: wrap up initial ash_blog behavior

This commit is contained in:
Zach Daniel 2022-11-01 03:11:06 -04:00
parent 2053a56c20
commit 56ca4116c6
9 changed files with 189 additions and 19 deletions

View file

@ -2,4 +2,67 @@ defmodule AshBlog do
@moduledoc """ @moduledoc """
Documentation for `AshBlog`. Documentation for `AshBlog`.
""" """
import XmlBuilder
def rss_feed(blog_posts, opts \\ []) do
blog_posts = Enum.filter(blog_posts, &(&1.state == :published))
element(
:feed,
%{xmlns: "http://www.w3.org/2005/Atom"},
[
element(:title, "Ash Framework Blog"),
element(:link, "https://ash-hq.org/blog"),
element(
:description,
"News and information about Ash Framework, a declarative, resource oriented Elixir application development framework."
)
] ++
Enum.map(blog_posts, fn %resource{} = blog_post ->
data = [
title: Map.get(blog_post, AshBlog.DataLayer.Info.title_attribute(resource))
]
data =
if opts[:linker] do
Keyword.put(data, :link, opts[:linker].(blog_post))
else
data
end
inners =
if opts[:html_body] do
[element(:content, %{type: "html"}, {:cdata, opts[:html_body].(blog_post)})]
else
[]
end
inners =
if opts[:summary] do
[element(:summary, %{type: "html"}, {:cdata, opts[:summary].(blog_post)}) | inners]
else
inners
end
inners =
if opts[:author] do
[element(:author, name: opts[:author].(blog_post)) | inners]
else
inners
end
data =
Keyword.put(
data,
:id,
Ash.Resource.Info.primary_key(resource)
|> Enum.map_join("-", &to_string(Map.get(blog_post, &1)))
)
element(:item, data, inners)
end)
)
|> XmlBuilder.generate()
end
end end

View file

@ -0,0 +1,45 @@
defmodule AshBlog.DataLayer.Changes.SetAndTrackSlug do
use Ash.Resource.Change
def change(changeset, _, _) do
slug_attribute = AshBlog.DataLayer.Info.slug_attribute(changeset.resource)
if changeset.action_type == :create do
if Ash.Changeset.get_attribute(changeset, :slug) do
changeset
else
set_default_slug(changeset, slug_attribute)
end
else
if Ash.Changeset.changing_attribute?(changeset, slug_attribute) && changeset.data.slug do
past_slugs = Ash.Changeset.get_attribute(changeset, :past_slugs) || []
Ash.Changeset.force_change_attribute(changeset, :past_slugs, [
changeset.data.slug | past_slugs
])
else
changeset
end
end
end
defp set_default_slug(changeset, slug_attribute) do
title_attribute = AshBlog.DataLayer.Info.title_attribute(changeset.resource)
Ash.Changeset.force_change_attribute(
changeset,
slug_attribute,
to_slug(Ash.Changeset.get_attribute(changeset, title_attribute))
)
end
defp to_slug(nil), do: nil
defp to_slug(title) do
title
|> String.replace(~r/\s+/, " ")
|> String.replace(" ", "-")
|> String.replace(~r/[^A-Za-z0-9-]/, "")
|> String.downcase()
end
end

View file

@ -41,6 +41,12 @@ defmodule AshBlog.DataLayer do
doc: doc:
"The attribute name to use for the body of the post. Wil be created if it doesn't exist." "The attribute name to use for the body of the post. Wil be created if it doesn't exist."
], ],
slug_attribute: [
type: :atom,
default: :slug,
doc:
"The attribute name to use for the slug. All past slugs will be stored and used when looking up by slug."
],
folder: [ folder: [
type: :string, type: :string,
default: "blog/published", default: "blog/published",
@ -468,7 +474,9 @@ defmodule AshBlog.DataLayer do
|> Ash.Resource.Info.attributes() |> Ash.Resource.Info.attributes()
|> Enum.reject(&(&1.name == body_attribute)) |> Enum.reject(&(&1.name == body_attribute))
|> Enum.reduce_while({:ok, []}, fn attr, {:ok, acc} -> |> Enum.reduce_while({:ok, []}, fn attr, {:ok, acc} ->
if Ash.Type.storage_type(attr.type) in [ storage_type = Ash.Type.storage_type(unwrap_array(attr.type))
if storage_type in [
:string, :string,
:integer, :integer,
:uuid, :uuid,
@ -483,7 +491,9 @@ defmodule AshBlog.DataLayer do
{:halt, {:error, error}} {:halt, {:error, error}}
end end
else else
{:halt, {:error, "#{inspect(attr.type)} is not yet supported by `AshBlog.DataLayer`"}} {:halt,
{:error,
"#{inspect(attr.type)} with storage type #{inspect(storage_type)} is not yet supported by `AshBlog.DataLayer`"}}
end end
end) end)
|> case do |> case do
@ -492,16 +502,7 @@ defmodule AshBlog.DataLayer do
attrs attrs
|> Enum.reverse() |> Enum.reverse()
|> Enum.map_join("\n", fn {name, value} -> |> Enum.map_join("\n", fn {name, value} ->
case value do "#{name}: #{encode(value)}"
value when is_binary(value) ->
"#{name}: '#{escape_string(value)}'"
%DateTime{} = value ->
"#{name}: '#{escape_string(value)}'"
other ->
"#{name}: #{other}"
end
end)} end)}
{:error, error} -> {:error, error} ->
@ -509,6 +510,34 @@ defmodule AshBlog.DataLayer do
end end
end end
def encode(value, indentation \\ 2) do
case value do
value when is_binary(value) ->
"'#{escape_string(value)}'"
%DateTime{} = value ->
"'#{escape_string(value)}'"
[] ->
"[]"
list when is_list(value) ->
"\n#{listify(list, indentation)}"
other ->
to_string(other)
end
end
def listify(value, indentation \\ 2) do
Enum.map_join(value, "\n", fn value ->
"#{String.duplicate(" ", indentation)}- #{encode(value, indentation + 2)}"
end)
end
defp unwrap_array({:array, value}), do: unwrap_array(value)
defp unwrap_array(value), do: value
defp escape_string(value) do defp escape_string(value) do
value value
|> to_string() |> to_string()
@ -647,7 +676,7 @@ defmodule AshBlog.DataLayer do
all_in_transaction(tx_identifiers, fn -> all_in_transaction(tx_identifiers, fn ->
try do try do
fun.() {:ok, fun.()}
catch catch
{{:blog_rollback, rolled_back_tx_identifiers}, value} = thrown -> {{:blog_rollback, rolled_back_tx_identifiers}, value} = thrown ->
if Enum.any?(tx_identifiers, &(&1 in rolled_back_tx_identifiers)) do if Enum.any?(tx_identifiers, &(&1 in rolled_back_tx_identifiers)) do
@ -660,7 +689,7 @@ defmodule AshBlog.DataLayer do
end end
defp all_in_transaction([], fun) do defp all_in_transaction([], fun) do
{:ok, fun.()} fun.()
end end
defp all_in_transaction([tx_identifier | rest], fun) do defp all_in_transaction([tx_identifier | rest], fun) do

View file

@ -29,6 +29,10 @@ defmodule AshBlog.DataLayer.Info do
Extension.get_opt(resource, [:blog], :body_attribute, :body) Extension.get_opt(resource, [:blog], :body_attribute, :body)
end end
def slug_attribute(resource) do
Extension.get_opt(resource, [:blog], :slug_attribute, :slug)
end
def title_attribute(resource) do def title_attribute(resource) do
Extension.get_opt(resource, [:blog], :title_attribute, :title) Extension.get_opt(resource, [:blog], :title_attribute, :title)
end end

View file

@ -9,21 +9,39 @@ defmodule AshBlog.DataLayer.Transformers.AddStructure do
|> Ash.Resource.Builder.add_new_attribute(Info.title_attribute(dsl_state), :string, |> Ash.Resource.Builder.add_new_attribute(Info.title_attribute(dsl_state), :string,
allow_nil?: false allow_nil?: false
) )
|> Ash.Resource.Builder.add_new_attribute(Info.slug_attribute(dsl_state), :string,
allow_nil?: false
)
|> Ash.Resource.Builder.add_new_attribute(:past_slugs, {:array, :string},
allow_nil?: false,
default: [],
writable?: false
)
|> Ash.Resource.Builder.add_new_attribute(Info.body_attribute(dsl_state), :string, |> Ash.Resource.Builder.add_new_attribute(Info.body_attribute(dsl_state), :string,
allow_nil?: false allow_nil?: false
) )
|> Ash.Resource.Builder.add_new_attribute(:state, :atom, |> Ash.Resource.Builder.add_new_attribute(:state, :atom,
constraints: [one_of: [:staged, :published, :archived]], constraints: [one_of: [:staged, :published, :archived]],
default: :staged default: :staged,
writable?: false
)
|> Ash.Resource.Builder.add_new_attribute(:published_at, :utc_datetime_usec, writable?: false)
|> Ash.Resource.Builder.add_change(AshBlog.DataLayer.Changes.SetAndTrackSlug,
on: [:create, :update]
) )
|> Ash.Resource.Builder.add_new_action(:update, :publish, |> Ash.Resource.Builder.add_new_action(:update, :publish,
accept: [],
changes: [ changes: [
Ash.Resource.Builder.build_action_change( Ash.Resource.Builder.build_action_change(
Ash.Resource.Change.Builtins.set_attribute(:state, :published) Ash.Resource.Change.Builtins.set_attribute(:state, :published)
),
Ash.Resource.Builder.build_action_change(
Ash.Resource.Change.Builtins.set_attribute(:published_at, &DateTime.utc_now/0)
) )
] ]
) )
|> Ash.Resource.Builder.add_new_action(:update, :stage, |> Ash.Resource.Builder.add_new_action(:update, :stage,
accept: [],
changes: [ changes: [
Ash.Resource.Builder.build_action_change( Ash.Resource.Builder.build_action_change(
Ash.Resource.Change.Builtins.set_attribute(:state, :staged) Ash.Resource.Change.Builtins.set_attribute(:state, :staged)
@ -31,6 +49,7 @@ defmodule AshBlog.DataLayer.Transformers.AddStructure do
] ]
) )
|> Ash.Resource.Builder.add_new_action(:update, :archive, |> Ash.Resource.Builder.add_new_action(:update, :archive,
accept: [],
changes: [ changes: [
Ash.Resource.Builder.build_action_change( Ash.Resource.Builder.build_action_change(
Ash.Resource.Change.Builtins.set_attribute(:state, :archived) Ash.Resource.Change.Builtins.set_attribute(:state, :archived)

View file

@ -109,9 +109,10 @@ defmodule AshBlog.MixProject do
# Run "mix help deps" to learn about dependencies. # Run "mix help deps" to learn about dependencies.
defp deps do defp deps do
[ [
{:ash, github: "ash-project/ash"}, # {:ash, github: "ash-project/ash"},
# {:ash, path: "../ash"}, {:ash, path: "../ash"},
{:yaml_elixir, "~> 2.9"}, {:yaml_elixir, "~> 2.9"},
{:xml_builder, "~> 2.2"},
# dev/test dependencies # dev/test dependencies
{:elixir_sense, {:elixir_sense,

View file

@ -33,14 +33,16 @@
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
"picosat_elixir": {:hex, :picosat_elixir, "0.2.2", "1cacfdb4fb0c3ead5e5e9b1e98ac822a777f07eab35e29c3f8fc7086de2bfb36", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9d0cc569552cca417abea8270a54b71153a63be4b951ff249e94642f1c0f35d1"}, "picosat_elixir": {:hex, :picosat_elixir, "0.2.2", "1cacfdb4fb0c3ead5e5e9b1e98ac822a777f07eab35e29c3f8fc7086de2bfb36", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9d0cc569552cca417abea8270a54b71153a63be4b951ff249e94642f1c0f35d1"},
"providers": {:hex, :providers, "1.8.1", "70b4197869514344a8a60e2b2a4ef41ca03def43cfb1712ecf076a0f3c62f083", [:rebar3], [{:getopt, "1.0.1", [hex: :getopt, repo: "hexpm", optional: false]}], "hexpm", "e45745ade9c476a9a469ea0840e418ab19360dc44f01a233304e118a44486ba0"}, "providers": {:hex, :providers, "1.8.1", "70b4197869514344a8a60e2b2a4ef41ca03def43cfb1712ecf076a0f3c62f083", [:rebar3], [{:getopt, "1.0.1", [hex: :getopt, repo: "hexpm", optional: false]}], "hexpm", "e45745ade9c476a9a469ea0840e418ab19360dc44f01a233304e118a44486ba0"},
"rss": {:hex, :rss, "0.2.1", "034f2fe5250a490862e692eb34a31bb5c142913c2fe0fb093e1fd982f010e15d", [:mix], [], "hexpm", "1af49c787fc789740a0fa7e0e197a7cb779a63c4eb703f013fea400126eac1f2"},
"sobelow": {:hex, :sobelow, "0.11.1", "23438964486f8112b41e743bbfd402da3e5b296fdc9eacab29914b79c48916dd", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9897363a7eff96f4809304a90aad819e2ad5e5d24db547af502885146746a53c"}, "sobelow": {:hex, :sobelow, "0.11.1", "23438964486f8112b41e743bbfd402da3e5b296fdc9eacab29914b79c48916dd", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9897363a7eff96f4809304a90aad819e2ad5e5d24db547af502885146746a53c"},
"sourceror": {:hex, :sourceror, "0.11.2", "549ce48be666421ac60cfb7f59c8752e0d393baa0b14d06271d3f6a8c1b027ab", [:mix], [], "hexpm", "9ab659118896a36be6eec68ff7b0674cba372fc8e210b1e9dc8cf2b55bb70dfb"}, "sourceror": {:hex, :sourceror, "0.11.2", "549ce48be666421ac60cfb7f59c8752e0d393baa0b14d06271d3f6a8c1b027ab", [:mix], [], "hexpm", "9ab659118896a36be6eec68ff7b0674cba372fc8e210b1e9dc8cf2b55bb70dfb"},
"spark": {:hex, :spark, "0.2.3", "3678177ca1f1f4c7919da90b49f5e378c39e1bdf2f59ad8909c0c3591fe8dbb6", [:mix], [{:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "933dadc7fcce93198104e7d88243772bbde9e54b001929a4c04bc733812e8e3b"}, "spark": {:hex, :spark, "0.2.6", "84dbfe7153dc51f988a2b43f28031be87dee724d2ac535069d05807cfacde7c4", [:mix], [{:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "f0fba891abc70d4e7431b3ed6283ee46ccd6e8045e0bfdce13bc06e8904bbd25"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"}, "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"},
"telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"},
"typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"}, "typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
"xml_builder": {:hex, :xml_builder, "2.2.0", "cc5f1eeefcfcde6e90a9b77fb6c490a20bc1b856a7010ce6396f6da9719cbbab", [:mix], [], "hexpm", "9d66d52fb917565d358166a4314078d39ef04d552904de96f8e73f68f64a62c9"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
"yaml_elixir": {:hex, :yaml_elixir, "2.9.0", "9a256da867b37b8d2c1ffd5d9de373a4fda77a32a45b452f1708508ba7bbcb53", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "0cb0e7d4c56f5e99a6253ed1a670ed0e39c13fc45a6da054033928607ac08dfc"}, "yaml_elixir": {:hex, :yaml_elixir, "2.9.0", "9a256da867b37b8d2c1ffd5d9de373a4fda77a32a45b452f1708508ba7bbcb53", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "0cb0e7d4c56f5e99a6253ed1a670ed0e39c13fc45a6da054033928607ac08dfc"},
} }

View file

@ -24,6 +24,13 @@ defmodule AshBlogTest do
end end
end end
describe "slug" do
test "a slug is auto generated" do
Post.create!("first", "the body") |> IO.inspect()
Post.read!()
end
end
describe "updating blog posts" do describe "updating blog posts" do
test "blog posts can be published" do test "blog posts can be published" do
post = Post.create!("first\"", "the body") post = Post.create!("first\"", "the body")

View file

@ -5,7 +5,7 @@ defmodule AshBlog.Test.Post do
data_layer: AshBlog.DataLayer data_layer: AshBlog.DataLayer
actions do actions do
defaults [:create, :read] defaults [:create, :read, :update]
end end
attributes do attributes do