create posts table

This commit is contained in:
sloane 2025-03-25 13:46:04 -04:00
parent d7ac169607
commit b051e06454
Signed by: sloanelybutsurely
SSH key fingerprint: SHA256:8SBnwhl+RY3oEyQxy1a9wByPzxWM0x+/Ejc+sIlY5qQ
21 changed files with 577 additions and 41 deletions

View file

@ -100,34 +100,34 @@
# Some of the rules have `priority: :high`, meaning Credo runs them unless we explicitly disable them
# (removing them from this file wouldn't be enough, the `false` is required)
#
{Credo.Check.Consistency.MultiAliasImportRequireUse, false},
{Credo.Check.Consistency.ParameterPatternMatching, false},
{Credo.Check.Design.AliasUsage, false},
{Credo.Check.Readability.AliasOrder, false},
{Credo.Check.Readability.BlockPipe, false},
{Credo.Check.Readability.LargeNumbers, false},
{Credo.Check.Readability.ModuleDoc, false},
{Credo.Check.Readability.MultiAlias, false},
{Credo.Check.Readability.OneArityFunctionInPipe, false},
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, false},
{Credo.Check.Readability.PipeIntoAnonymousFunctions, false},
{Credo.Check.Readability.PreferImplicitTry, false},
{Credo.Check.Readability.SinglePipe, false},
{Credo.Check.Readability.StrictModuleLayout, false},
{Credo.Check.Readability.StringSigils, false},
{Credo.Check.Readability.UnnecessaryAliasExpansion, false},
{Credo.Check.Readability.WithSingleClause, false},
{Credo.Check.Refactor.CaseTrivialMatches, false},
{Credo.Check.Refactor.CondStatements, false},
{Credo.Check.Refactor.FilterCount, false},
{Credo.Check.Refactor.MapInto, false},
{Credo.Check.Refactor.MapJoin, false},
{Credo.Check.Refactor.NegatedConditionsInUnless, false},
{Credo.Check.Refactor.NegatedConditionsWithElse, false},
{Credo.Check.Refactor.PipeChainStart, false},
{Credo.Check.Refactor.RedundantWithClauseResult, false},
{Credo.Check.Refactor.UnlessWithElse, false},
{Credo.Check.Refactor.WithClauses, false},
# {Credo.Check.Consistency.MultiAliasImportRequireUse, false},
# {Credo.Check.Consistency.ParameterPatternMatching, false},
# {Credo.Check.Design.AliasUsage, false},
# {Credo.Check.Readability.AliasOrder, false},
# {Credo.Check.Readability.BlockPipe, false},
# {Credo.Check.Readability.LargeNumbers, false},
# {Credo.Check.Readability.ModuleDoc, false},
# {Credo.Check.Readability.MultiAlias, false},
# {Credo.Check.Readability.OneArityFunctionInPipe, false},
# {Credo.Check.Readability.ParenthesesOnZeroArityDefs, false},
# {Credo.Check.Readability.PipeIntoAnonymousFunctions, false},
# {Credo.Check.Readability.PreferImplicitTry, false},
# {Credo.Check.Readability.SinglePipe, false},
# {Credo.Check.Readability.StrictModuleLayout, false},
# {Credo.Check.Readability.StringSigils, false},
# {Credo.Check.Readability.UnnecessaryAliasExpansion, false},
# {Credo.Check.Readability.WithSingleClause, false},
# {Credo.Check.Refactor.CaseTrivialMatches, false},
# {Credo.Check.Refactor.CondStatements, false},
# {Credo.Check.Refactor.FilterCount, false},
# {Credo.Check.Refactor.MapInto, false},
# {Credo.Check.Refactor.MapJoin, false},
# {Credo.Check.Refactor.NegatedConditionsInUnless, false},
# {Credo.Check.Refactor.NegatedConditionsWithElse, false},
# {Credo.Check.Refactor.PipeChainStart, false},
# {Credo.Check.Refactor.RedundantWithClauseResult, false},
# {Credo.Check.Refactor.UnlessWithElse, false},
# {Credo.Check.Refactor.WithClauses, false},
#
# Checks scheduled for next check update (opt-in for now)

View file

@ -1,6 +1,6 @@
[
import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"],
plugins: [Styler, Phoenix.LiveView.HTMLFormatter],
plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
]

View file

@ -3,7 +3,8 @@ import Config
config :esbuild,
version: "0.17.11",
sloanely_but_surely: [
args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
args:
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
@ -27,7 +28,9 @@ config :sloanely_but_surely, Web.Endpoint,
config :sloanely_but_surely,
namespace: Core,
ecto_repos: [Core.Repo],
generators: [timestamp_type: :utc_datetime, binary_id: true]
generators: [timestamp_type: :utc_datetime_usec, binary_id: true]
config :sloanely_but_surely, Core.Repo, migration_timestamps: [type: :utc_datetime_usec]
config :tailwind,
version: "3.4.3",

View file

@ -1,4 +1,4 @@
defmodule Core do
@moduledoc false
use Boundary, deps: [Schema], exports: [Accounts]
use Boundary, deps: [Schema], exports: [Accounts, Posts]
end

55
lib/core/posts.ex Normal file
View file

@ -0,0 +1,55 @@
defmodule Core.Posts do
@moduledoc false
alias Core.Posts.Post
def get!(id) do
Core.Repo.get!(Schema.Post, id)
end
def get_by_publish_date_and_slug!(%Date{} = publish_date, slug) when is_binary(slug) do
publish_date
|> Post.Query.where_publish_date_and_slug(slug)
|> Core.Repo.one!()
end
def change_post(%Schema.Post{} = post \\ %Schema.Post{}, attrs) do
Post.content_changeset(post, attrs)
end
def create_post(attrs) do
attrs
|> change_post()
|> Core.Repo.insert()
end
def update_post(%Schema.Post{} = post, attrs) do
post
|> change_post(attrs)
|> Core.Repo.update()
end
def publish_post(%Schema.Post{} = post, published_at \\ DateTime.utc_now()) do
post
|> Post.publish_changeset(published_at)
|> Core.Repo.update()
end
def delete_post(%Schema.Post{} = post, deleted_at \\ DateTime.utc_now()) do
post
|> Post.delete_changeset(deleted_at)
|> Core.Repo.update()
end
def unpublish_post(%Schema.Post{} = post) do
post
|> Post.unpublish_changeset()
|> Core.Repo.update()
end
def undelete_post(%Schema.Post{} = post) do
post
|> Post.undelete_changeset()
|> Core.Repo.update()
end
end

100
lib/core/posts/post.ex Normal file
View file

@ -0,0 +1,100 @@
defmodule Core.Posts.Post do
@moduledoc false
import Ecto.Changeset
def content_changeset(%Schema.Post{} = post, attrs) do
changeset =
post
|> cast(attrs, [:tid, :kind, :slug, :title, :body])
|> validate_required([:kind], message: "must have a kind")
|> validate_required([:body], message: "must have a body")
changeset =
case get_field(changeset, :kind) do
:blog ->
validate_required(changeset, [:title, :slug])
:status ->
changeset
|> put_change(:slug, nil)
|> put_change(:title, nil)
_ ->
changeset
end
changeset
|> validate_format(:slug, ~r/^[a-z](?:[a-z-]*[a-z])?$/)
|> unique_constraint([:slug])
end
def publish_changeset(%Schema.Post{} = post, published_at) do
changeset = change(post)
if is_nil(get_field(changeset, :published_at)) do
put_change(changeset, :published_at, published_at)
else
changeset
end
end
def delete_changeset(%Schema.Post{} = post, deleted_at) do
changeset = change(post)
if is_nil(get_field(changeset, :deleted_at)) do
put_change(changeset, :deleted_at, deleted_at)
else
changeset
end
end
def unpublish_changeset(%Schema.Post{} = post) do
post
|> change()
|> put_change(:published_at, nil)
end
def undelete_changeset(%Schema.Post{} = post) do
post
|> change()
|> put_change(:deleted_at, nil)
end
defmodule Query do
@moduledoc false
import Ecto.Query
def base do
from _ in Schema.Post, as: :posts
end
def current(query \\ base()) do
where(query, [posts: p], is_nil(p.deleted_at))
end
def default_order(query \\ base()) do
order_by(query, [posts: p], desc: :inserted_at, desc: :updated_at)
end
def where_publish_date_and_slug(query \\ current(), publish_date, slug) do
where(
query,
[posts: p],
p.slug == ^slug and
fragment("(? at time zone 'UTC' at time zone 'America/New_York')::date", p.published_at) ==
^publish_date
)
end
def published(query \\ base()) do
query
|> current()
|> where([posts: p], not is_nil(p.published_at))
end
def deleted(query \\ base()) do
where(query, [posts: p], not is_nil(p.deleted_at))
end
end
end

View file

@ -1,4 +1,15 @@
defmodule Schema do
@moduledoc false
use Boundary, deps: [], exports: [User, UserToken]
use Boundary, deps: [], exports: [Post, User, UserToken]
defmacro __using__(_) do
quote do
use Ecto.Schema
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@type t() :: %__MODULE__{}
end
end
end

21
lib/schema/post.ex Normal file
View file

@ -0,0 +1,21 @@
defmodule Schema.Post do
@moduledoc false
use Schema
@post_kinds ~w[status blog]a
schema "posts" do
field :tid, :string
field :kind, Ecto.Enum, values: @post_kinds
field :slug, :string
field :title, :string
field :body, :string
field :published_at, :utc_datetime_usec
field :deleted_at, :utc_datetime_usec
timestamps()
end
def kinds, do: @post_kinds
end

View file

@ -1,9 +1,7 @@
defmodule Schema.User do
@moduledoc false
use Ecto.Schema
use Schema
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users" do
field :username, :string
field :password, :string, virtual: true, redact: true

View file

@ -1,9 +1,7 @@
defmodule Schema.UserToken do
@moduledoc false
use Ecto.Schema
use Schema
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users_tokens" do
field :token, :binary
field :context, :string

View file

@ -0,0 +1,21 @@
defmodule Web.PostController do
use Web, :controller
def show(conn, %{"year" => year, "month" => month, "day" => day, "slug" => slug}) do
with {year, ""} <- Integer.parse(year),
{month, ""} <- Integer.parse(month),
{day, ""} <- Integer.parse(day),
{:ok, publish_date} <- Date.new(year, month, day) do
post = Core.Posts.get_by_publish_date_and_slug!(publish_date, slug)
conn
|> render_post(post)
else
_ -> raise Ecto.NoResultsError
end
end
defp render_post(conn, %Schema.Post{kind: :blog} = blog) do
render(conn, :show_blog, blog: blog)
end
end

View file

@ -0,0 +1,5 @@
defmodule Web.PostHTML do
use Web, :html
embed_templates "post_html/*"
end

View file

@ -0,0 +1,9 @@
<article>
<header class="mb-2">
<h1 class="text-2xl font-bold">{@blog.title}</h1>
</header>
<p>{@blog.body}</p>
<%!-- <footer class="mt-4 border-t border-gray-200"></footer> --%>
</article>

View file

@ -40,6 +40,8 @@ defmodule Web.Router do
delete "/admin/users/log_out", UserSessionController, :delete
get "/:year/:month/:day/:slug", PostController, :show
# live_session :current_user, on_mount: [{Web.UserAuth, :mount_current_user}] do
# end
end

View file

@ -55,8 +55,8 @@ defmodule SlaonelyButSurely.MixProject do
{:boundary, "~> 0.10.4"},
# Added dev and/or test dependencies
{:styler, "~> 1.4", only: [:dev, :test], runtime: false},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:faker, "~> 0.18", only: :test}
]
end

View file

@ -16,6 +16,7 @@
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.9.0", "f043eeaca4932ca8e16e5429aebd90f7766f31ac160a25cbd9befe84f2bc068f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b415027f71d5ab57ef2be844b2a10d0c1b5a492d431727f43937adce22ba45ae"},
"expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"},
"faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"},
"file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
"floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},

View file

@ -0,0 +1,35 @@
defmodule Core.Repo.Migrations.CreatePostsTable do
use Ecto.Migration
def change do
create table(:posts, primary_key: false) do
add :id, :uuid, primary_key: true
add :tid, :text
add :kind, :text, null: false
add :body, :text, null: false
# blog only fields
add :slug, :text
add :title, :text
add :published_at, :utc_datetime_usec
add :deleted_at, :utc_datetime_usec
timestamps()
end
create index(:posts, [:kind])
create unique_index(:posts, [:slug])
create index(:posts, [:published_at])
create index(
:posts,
["((published_at at time zone 'UTC' at time zone 'America/New_York')::date)"],
name: :posts_published_at_date_index
)
create index(:posts, [:deleted_at])
create index(:posts, [:inserted_at])
create index(:posts, [:updated_at])
end
end

194
test/core/posts_test.exs Normal file
View file

@ -0,0 +1,194 @@
defmodule Test.Core.PostsTest do
use Test.DataCase, async: true
describe "get!/1" do
@tag post: :post
test "returns the post with the given id", %{posts: %{post: post}} do
assert result = Core.Posts.get!(post.id)
assert %Schema.Post{} = result
assert result.id == post.id
end
test "raises an Ecto.NoResultsError if there is no matching post" do
assert_raise Ecto.NoResultsError, fn ->
Core.Posts.get!(Faker.UUID.v4())
end
end
end
describe "get_by_publish_date_and_slug!/2" do
@tag blog: :blog
test "returns the post with the given publish date and slug", %{blogs: %{blog: blog}} do
assert {:ok, blog} = Core.Posts.publish_post(blog)
publish_date = DateTime.to_date(blog.published_at)
assert result = Core.Posts.get_by_publish_date_and_slug!(publish_date, blog.slug)
assert %Schema.Post{} = result
assert result.id == blog.id
end
@tag blog: :blog
test "raises an Ecto.NoResultsError if there is no matching post", %{blogs: %{blog: blog}} do
assert_raise Ecto.NoResultsError, fn ->
Core.Posts.get_by_publish_date_and_slug!(~D[1993-11-28], "first-day-on-earth")
end
assert {:ok, blog} = Core.Posts.publish_post(blog)
assert_raise Ecto.NoResultsError, fn ->
Core.Posts.get_by_publish_date_and_slug!(~D[1993-11-28], blog.slug)
end
end
end
describe "change_post/1/2" do
test "returns a changeset" do
assert %Ecto.Changeset{} = Core.Posts.change_post(%{})
assert %Ecto.Changeset{} = Core.Posts.change_post(%Schema.Post{}, %{})
assert %Ecto.Changeset{} =
Test.Fixtures.Posts.blog_post(:blog)
|> Core.Posts.change_post()
assert %Ecto.Changeset{} =
%Schema.Post{}
|> Core.Posts.change_post(Test.Fixtures.Posts.blog_post(:blog))
assert %Ecto.Changeset{} =
Test.Fixtures.Posts.status_post(:status)
|> Core.Posts.change_post()
assert %Ecto.Changeset{} =
%Schema.Post{}
|> Core.Posts.change_post(Test.Fixtures.Posts.status_post(:status))
end
end
describe "create_post/1" do
test "creates a status post" do
assert {:ok, %Schema.Post{}} =
Test.Fixtures.Posts.status_post(:status)
|> Core.Posts.create_post()
end
test "creates a blug post" do
assert {:ok, %Schema.Post{}} =
Test.Fixtures.Posts.blog_post(:blog)
|> Core.Posts.create_post()
end
test "returns an error changeset for invalid attrs" do
assert {:error, %Ecto.Changeset{} = changeset} = Core.Posts.create_post(%{})
refute changeset.valid?
assert "must have a kind" in errors_on(changeset).kind
assert "must have a body" in errors_on(changeset).body
assert {:error, %Ecto.Changeset{} = changeset} = Core.Posts.create_post(%{kind: :blog})
refute changeset.valid?
assert "must have a body" in errors_on(changeset).body
assert "can't be blank" in errors_on(changeset).title
assert "can't be blank" in errors_on(changeset).slug
assert {:error, %Ecto.Changeset{} = changeset} =
Core.Posts.create_post(%{
kind: :blog,
title: "Hello, World!",
slug: "hello-world!",
body: "This is a sample blog posts."
})
refute changeset.valid?
assert "has invalid format" in errors_on(changeset).slug
end
end
describe "update_post/2" do
@tag blog: :to_update
test "updates a blog post", %{blogs: %{to_update: post}} do
assert {:ok, %Schema.Post{} = post} = Core.Posts.update_post(post, %{title: "A new title"})
assert post.tid == "to_update"
assert post.title == "A new title"
end
@tag status: :to_update
test "updates a status post", %{statuses: %{to_update: post}} do
assert {:ok, %Schema.Post{} = post} = Core.Posts.update_post(post, %{body: "Hello, World!"})
assert post.tid == "to_update"
assert post.body == "Hello, World!"
end
end
describe "publish_post/1/2" do
@describetag post: :to_publish
test "publishes a post", %{posts: %{to_publish: to_publish}} do
refute to_publish.published_at
assert {:ok, published} = Core.Posts.publish_post(to_publish)
assert published.tid == "to_publish"
assert published.published_at
end
test "does not update an already published post", %{posts: %{to_publish: to_publish}} do
assert {:ok, published} = Core.Posts.publish_post(to_publish)
assert {:ok, published_again} = Core.Posts.publish_post(published)
assert published.updated_at == published_again.updated_at
end
end
describe "delete_post/1/2" do
@describetag post: :to_delete
test "soft deletes a post", %{posts: %{to_delete: post}} do
refute post.deleted_at
assert {:ok, post} = Core.Posts.delete_post(post)
assert post.deleted_at
end
test "does not update an already deleted post", %{posts: %{to_delete: to_delete}} do
assert {:ok, deleted} = Core.Posts.delete_post(to_delete)
assert {:ok, deleted_again} = Core.Posts.delete_post(deleted)
assert deleted.updated_at == deleted_again.updated_at
end
end
describe "unpublish_post/1" do
@describetag posts: [:published, :not_published]
setup %{posts: %{published: published, not_published: not_published}} do
{:ok, published} = Core.Posts.publish_post(published)
[posts: %{published: published, not_published: not_published}]
end
test "unpublishes a post", %{posts: %{published: published}} do
assert published.published_at
assert {:ok, unpublished} = Core.Posts.unpublish_post(published)
refute unpublished.published_at
end
test "does not update a post that isn't published", %{posts: %{not_published: not_published}} do
refute not_published.published_at
assert {:ok, not_updated} = Core.Posts.unpublish_post(not_published)
assert not_published.updated_at == not_updated.updated_at
end
end
describe "undelete_post/1" do
@describetag posts: [:deleted, :not_deleted]
setup %{posts: %{deleted: deleted, not_deleted: not_deleted}} do
{:ok, deleted} = Core.Posts.delete_post(deleted)
[posts: %{deleted: deleted, not_deleted: not_deleted}]
end
test "undeletees a post", %{posts: %{deleted: deleted}} do
assert deleted.deleted_at
assert {:ok, undeleted} = Core.Posts.undelete_post(deleted)
refute undeleted.deleted_at
end
test "does not update a post that isn't deleted", %{posts: %{not_deleted: not_deleted}} do
refute not_deleted.deleted_at
assert {:ok, not_updated} = Core.Posts.undelete_post(not_deleted)
assert not_deleted.updated_at == not_updated.updated_at
end
end
end

View file

@ -16,6 +16,8 @@ defmodule Test.DataCase do
use ExUnit.CaseTemplate
import Test.SharedSetup
alias Ecto.Adapters.SQL.Sandbox
using do
@ -34,6 +36,8 @@ defmodule Test.DataCase do
:ok
end
setup [:handle_post_tags, :handle_blog_tags, :handle_status_tags]
@doc """
Sets up the sandbox based on the test tags.
"""

31
test/support/test/fixtures/posts.ex vendored Normal file
View file

@ -0,0 +1,31 @@
defmodule Test.Fixtures.Posts do
@moduledoc false
def status_post(tid, attrs \\ %{}) do
Enum.into(attrs, %{
tid: "#{tid}",
kind: :status,
body: Faker.Lorem.paragraph(),
title: nil,
status: nil
})
end
def blog_post(tid, attrs \\ %{}) do
title = Faker.Lorem.sentence()
slug =
title
|> String.downcase()
|> String.replace(~r/[^\w]/, "-")
|> String.trim("-")
Enum.into(attrs, %{
tid: "#{tid}",
kind: :blog,
title: title,
slug: slug,
body: Enum.join(Faker.Lorem.paragraphs(), "\n\n")
})
end
end

View file

@ -0,0 +1,48 @@
defmodule Test.SharedSetup do
def handle_post_tags(tags) do
posts =
for tid <- get_test_ids(tags, [:post, :posts]), into: %{} do
fun = Enum.random([&Test.Fixtures.Posts.blog_post/1, &Test.Fixtures.Posts.status_post/1])
{:ok, post} = Core.Posts.create_post(fun.(tid))
{tid, post}
end
[posts: posts]
end
def handle_blog_tags(tags) do
blogs =
for tid <- get_test_ids(tags, [:blog, :blogs]), into: %{} do
{:ok, blog} =
Test.Fixtures.Posts.blog_post(tid)
|> Core.Posts.create_post()
{tid, blog}
end
[blogs: blogs]
end
def handle_status_tags(tags) do
statuses =
for tid <- get_test_ids(tags, [:status, :statuses]), into: %{} do
{:ok, status} =
Test.Fixtures.Posts.status_post(tid)
|> Core.Posts.create_post()
{tid, status}
end
[statuses: statuses]
end
defp get_test_ids(tags, keys) do
keys
|> Enum.flat_map(fn key ->
tags
|> Map.get(key)
|> List.wrap()
end)
|> Enum.uniq()
end
end