add an ets data layer, and start on some testing

This commit is contained in:
Zach Daniel 2019-11-28 00:24:29 -05:00
parent e3cc1f9b3a
commit 4cabb8a838
No known key found for this signature in database
GPG key ID: A57053A671EE649E
14 changed files with 497 additions and 123 deletions

View file

@ -24,3 +24,4 @@
* Since actions contain rules now, consider making it possible to list each action as its own `do` block, with an internal DSL for configuring the action. (overkill?)
* Validate rules at creation
* Maybe fix the crappy parts of optimal and bring it in for opts validation?
* The ecto internals that live on structs are going to cause problems w/ pluggability of backends, like the `%Ecto.Association.NotLoaded{}`. That backend may need to scrub the ecto specifics off of those structs.

View file

@ -1 +1,8 @@
use Mix.Config
if Mix.env() == :test do
config :ash,
resources: [
Ash.Test.Post
]
end

View file

@ -89,9 +89,24 @@ defmodule Ash do
end
def read(resource, params \\ %{}) do
action = Map.get(params, :action) || primary_action(resource, :read)
case Map.get(params, :action) || primary_action(resource, :read) do
nil ->
{:error, "no action provided, and no primary action found"}
action ->
Ash.DataLayer.Actions.run_read_action(resource, action, params)
end
end
def create(resource, params) do
case Map.get(params, :action) || primary_action(resource, :create) do
nil ->
{:error, "no action provided, and no primary action found"}
action ->
Ash.DataLayer.Actions.run_create_action(resource, action, params)
end
end
# # TODO: auth
# def create(resource, attributes, relationships, params \\ %{}) do

View file

@ -1,109 +0,0 @@
defmodule Ash.Data do
# @spec create(Ash.resource(), Ash.action(), Ash.attributes(), Ash.relationships(), Ash.params()) ::
# {:ok, Ash.record()} | {:error, Ash.error()}
# def create(resource, action, attributes, relationships, params) do
# Ash.data_layer(resource).create(resource, action, attributes, relationships, params)
# end
# @spec update(Ash.record(), Ash.action(), Ash.attributes(), Ash.relationships(), Ash.params()) ::
# {:ok, Ash.record()} | {:error, Ash.error()}
# def update(%resource{} = record, action, attributes, relationships, params) do
# Ash.data_layer(resource).update(record, action, attributes, relationships, params)
# end
# @spec delete(Ash.record(), Ash.action(), Ash.params()) ::
# {:ok, Ash.record()} | {:error, Ash.error()}
# def delete(%resource{} = record, action, params) do
# Ash.data_layer(resource).delete(record, action, params)
# end
# @spec append_related(Ash.record(), Ash.relationship(), Ash.resource_identifiers()) ::
# {:ok, Ash.record()} | {:error, Ash.error()}
# def append_related(%resource{} = record, relationship, resource_identifiers) do
# Ash.data_layer(resource).append_related(record, relationship, resource_identifiers)
# end
# @spec delete_related(Ash.record(), Ash.relationship(), Ash.resource_identifiers()) ::
# {:ok, Ash.record()} | {:error, Ash.error()}
# def delete_related(%resource{} = record, relationship, resource_identifiers) do
# Ash.data_layer(resource).delete_related(record, relationship, resource_identifiers)
# end
# @spec replace_related(Ash.record(), Ash.relationship(), Ash.resource_identifiers()) ::
# {:ok, Ash.record()} | {:error, Ash.error()}
# def replace_related(%resource{} = record, relationship, resource_identifiers) do
# Ash.data_layer(resource).replace_related(record, relationship, resource_identifiers)
# end
@spec resource_to_query(Ash.resource()) :: Ash.query()
def resource_to_query(resource) do
Ash.data_layer(resource).resource_to_query(resource)
end
@spec filter(Ash.resource(), Ash.query(), Ash.params()) ::
{:ok, Ash.query()} | {:error, Ash.error()}
# TODO This is a really dumb implementation of this.
def filter(resource, query, params) do
data_layer = Ash.data_layer(resource)
filtered_query =
params
|> Map.get(:filter, %{})
|> Enum.reduce(query, fn {key, value}, query ->
case query do
{:error, error} ->
{:error, error}
query ->
case data_layer.filter(query, key, value, resource) do
{:ok, query} -> query
{:error, query} -> {:error, query}
end
end
end)
{:ok, filtered_query}
end
@spec limit(Ash.query(), limit :: non_neg_integer, Ash.resource()) ::
{:ok, Ash.query()} | {:error, Ash.error()}
def limit(query, limit, resource) do
data_layer = Ash.data_layer(resource)
data_layer.limit(query, limit, resource)
end
@spec offset(Ash.query(), offset :: non_neg_integer, Ash.resource()) ::
{:ok, Ash.query()} | {:error, Ash.error()}
def offset(query, offset, resource) do
data_layer = Ash.data_layer(resource)
data_layer.limit(query, offset, resource)
end
# @spec get_related(Ash.record(), Ash.relationship()) ::
# {:ok, list(Ash.record()) | Ash.record() | nil} | {:error, Ash.error()}
# def get_related(record, %{cardinality: :many} = relationship) do
# case relationship_query(record, relationship) do
# {:ok, query} ->
# get_many(query, Ash.to_resource(record))
# {:error, error} ->
# {:error, error}
# end
# end
# def get_related(record, %{cardinality: :one} = relationship) do
# case relationship_query(record, relationship) do
# {:ok, query} ->
# get_one(query, Ash.to_resource(record))
# {:error, error} ->
# {:error, error}
# end
# end
@spec run_query(Ash.query(), central_resource :: Ash.resource()) ::
{:ok, list(Ash.record())} | {:error, Ash.error()}
def run_query(query, central_resource) do
Ash.data_layer(central_resource).run_query(query, central_resource)
end
end

View file

@ -36,11 +36,11 @@ defmodule Ash.DataLayer.Actions do
with {%{prediction: prediction} = instructions, per_check_data}
when prediction != :unauthorized <-
maybe_authorize_precheck(auth?, user, action.rules, auth_context),
query <- Ash.Data.resource_to_query(resource),
{:ok, filtered_query} <- Ash.Data.filter(resource, query, params),
query <- Ash.DataLayer.resource_to_query(resource),
{:ok, filtered_query} <- Ash.DataLayer.filter(resource, query, params),
{:ok, paginator} <-
Ash.DataLayer.Paginator.paginate(resource, action, filtered_query, params),
{:ok, found} <- Ash.Data.run_query(paginator.query, resource),
{:ok, found} <- Ash.DataLayer.run_query(paginator.query, resource),
{:ok, side_loaded_for_auth} <-
Ash.DataLayer.SideLoader.side_load(
resource,
@ -79,6 +79,109 @@ defmodule Ash.DataLayer.Actions do
end
end
def run_create_action(resource, action, params) do
auth_context = %{
resource: resource,
action: action,
params: params
}
user = Map.get(params, :user)
auth? = Map.get(params, :authorize?, false)
# TODO: no instrutions relevant to creates right now?
with {:ok, attributes, relationships} <- prepare_create_params(resource, params),
{%{prediction: prediction}, per_check_data}
when prediction != :unauthorized <-
maybe_authorize_precheck(auth?, user, action.rules, auth_context),
{:ok, created} <-
Ash.DataLayer.create(resource, attributes, relationships),
:allow <-
maybe_authorize(
auth?,
user,
created,
action.rules,
auth_context,
per_check_data
),
{:ok, side_loaded} <-
Ash.DataLayer.SideLoader.side_load(
resource,
created,
Map.get(params, :side_load, []),
Map.take(params, [:authorize?, :user])
) do
{:ok, side_loaded}
else
{:error, error} ->
{:error, error}
{%{prediction: :unauthorized}, _} ->
# TODO: Nice errors here!
{:error, :unauthorized}
{:unauthorized, _data} ->
# TODO: Nice errors here!
{:error, :unauthorized}
end
end
defp prepare_create_params(resource, params) do
attributes = Map.get(params, :attributes, %{})
relationships = Map.get(params, :relationships, %{})
with {:ok, attributes} <- prepare_create_attributes(resource, attributes),
{:ok, relationships} <- prepare_create_relationships(resource, relationships) do
{:ok, attributes, relationships}
else
{:error, error} ->
{:error, error}
end
end
defp prepare_create_attributes(resource, attributes) do
# resource_attributes = Ash.attributes(resource)
attributes
# Eventually we'll have to just copy changeset's logic
# and/or use it directly (now that ecto is split up, maybe thats the way to do all of this?)
|> Enum.reduce({%{}, []}, fn {key, value}, {changes, errors} ->
case Ash.attribute(resource, key) do
nil ->
{changes, ["unknown attribute #{key}" | errors]}
_attribute ->
# TODO do actual value validation here
{Map.put(changes, key, value), errors}
end
end)
|> case do
{changes, []} -> {:ok, changes}
{_, errors} -> {:error, errors}
end
end
defp prepare_create_relationships(resource, relationships) do
relationships
# Eventually we'll have to just copy changeset's logic
# and/or use it directly (now that ecto is split up, maybe thats the way to do all of this?)
|> Enum.reduce({%{}, []}, fn {key, value}, {changes, errors} ->
case Ash.attribute(resource, key) do
nil ->
{changes, ["unknown attribute #{key}" | errors]}
_attribute ->
# TODO do actual value validation here
{Map.put(changes, key, value), errors}
end
end)
|> case do
{changes, []} -> {:ok, changes}
{_, errors} -> {:error, errors}
end
end
defp maybe_authorize(false, _, _, _, _, _), do: :allow
defp maybe_authorize(true, user, data, rules, auth_context, per_check_data) do

View file

@ -6,11 +6,11 @@ defmodule Ash.DataLayer do
@callback offset(Ash.query(), offset :: non_neg_integer(), resource :: Ash.resource()) ::
{:ok, Ash.query()} | {:error, Ash.error()}
@callback resource_to_query(Ash.resource()) :: Ash.query()
# @callback relationship_query(Ash.record() | list(Ash.record()), Ash.relationship()) ::
# Ash.query()
@callback can_query_async?(Ash.resource()) :: boolean
@callback run_query(Ash.query(), Ash.resource()) ::
{:ok, list(Ash.resource())} | {:error, Ash.error()}
@callback create(Ash.resource(), attributes :: map(), relationships :: map()) ::
{:ok, Ash.resource()} | {:error, Ash.error()}
# @callback create(
# Ash.resource(),
@ -41,4 +41,117 @@ defmodule Ash.DataLayer do
# @callback replace_related(Ash.record(), Ash.relationship(), Ash.resource_identifiers()) ::
# {:ok, Ash.record()} | {:error, Ash.error()}
# @spec create(Ash.resource(), Ash.action(), Ash.attributes(), Ash.relationships(), Ash.params()) ::
# {:ok, Ash.record()} | {:error, Ash.error()}
# def create(resource, action, attributes, relationships, params) do
# Ash.data_layer(resource).create(resource, action, attributes, relationships, params)
# end
# @spec update(Ash.record(), Ash.action(), Ash.attributes(), Ash.relationships(), Ash.params()) ::
# {:ok, Ash.record()} | {:error, Ash.error()}
# def update(%resource{} = record, action, attributes, relationships, params) do
# Ash.data_layer(resource).update(record, action, attributes, relationships, params)
# end
# @spec delete(Ash.record(), Ash.action(), Ash.params()) ::
# {:ok, Ash.record()} | {:error, Ash.error()}
# def delete(%resource{} = record, action, params) do
# Ash.data_layer(resource).delete(record, action, params)
# end
# @spec append_related(Ash.record(), Ash.relationship(), Ash.resource_identifiers()) ::
# {:ok, Ash.record()} | {:error, Ash.error()}
# def append_related(%resource{} = record, relationship, resource_identifiers) do
# Ash.data_layer(resource).append_related(record, relationship, resource_identifiers)
# end
# @spec delete_related(Ash.record(), Ash.relationship(), Ash.resource_identifiers()) ::
# {:ok, Ash.record()} | {:error, Ash.error()}
# def delete_related(%resource{} = record, relationship, resource_identifiers) do
# Ash.data_layer(resource).delete_related(record, relationship, resource_identifiers)
# end
# @spec replace_related(Ash.record(), Ash.relationship(), Ash.resource_identifiers()) ::
# {:ok, Ash.record()} | {:error, Ash.error()}
# def replace_related(%resource{} = record, relationship, resource_identifiers) do
# Ash.data_layer(resource).replace_related(record, relationship, resource_identifiers)
# end
@spec resource_to_query(Ash.resource()) :: Ash.query()
def resource_to_query(resource) do
Ash.data_layer(resource).resource_to_query(resource)
end
@spec create(Ash.resource(), map, map) :: {:ok, Ash.record()} | {:error, Ash.error()}
def create(resource, attributes, relationships) do
Ash.data_layer(resource).create(resource, attributes, relationships)
end
@spec filter(Ash.resource(), Ash.query(), Ash.params()) ::
{:ok, Ash.query()} | {:error, Ash.error()}
# TODO This is a really dumb implementation of this.
def filter(resource, query, params) do
data_layer = Ash.data_layer(resource)
filtered_query =
params
|> Map.get(:filter, %{})
|> Enum.reduce(query, fn {key, value}, query ->
case query do
{:error, error} ->
{:error, error}
query ->
case data_layer.filter(query, key, value, resource) do
{:ok, query} -> query
{:error, query} -> {:error, query}
end
end
end)
{:ok, filtered_query}
end
@spec limit(Ash.query(), limit :: non_neg_integer, Ash.resource()) ::
{:ok, Ash.query()} | {:error, Ash.error()}
def limit(query, limit, resource) do
data_layer = Ash.data_layer(resource)
data_layer.limit(query, limit, resource)
end
@spec offset(Ash.query(), offset :: non_neg_integer, Ash.resource()) ::
{:ok, Ash.query()} | {:error, Ash.error()}
def offset(query, offset, resource) do
data_layer = Ash.data_layer(resource)
data_layer.limit(query, offset, resource)
end
# @spec get_related(Ash.record(), Ash.relationship()) ::
# {:ok, list(Ash.record()) | Ash.record() | nil} | {:error, Ash.error()}
# def get_related(record, %{cardinality: :many} = relationship) do
# case relationship_query(record, relationship) do
# {:ok, query} ->
# get_many(query, Ash.to_resource(record))
# {:error, error} ->
# {:error, error}
# end
# end
# def get_related(record, %{cardinality: :one} = relationship) do
# case relationship_query(record, relationship) do
# {:ok, query} ->
# get_one(query, Ash.to_resource(record))
# {:error, error} ->
# {:error, error}
# end
# end
@spec run_query(Ash.query(), central_resource :: Ash.resource()) ::
{:ok, list(Ash.record())} | {:error, Ash.error()}
def run_query(query, central_resource) do
Ash.data_layer(central_resource).run_query(query, central_resource)
end
end

112
lib/ash/data_layer/ets.ex Normal file
View file

@ -0,0 +1,112 @@
defmodule Ash.DataLayer.Ets do
@moduledoc """
An ETS backed Ash Datalayer. Should only be used for testing, or for
unimportant/small datasets.
"""
defmacro __using__(opts) do
quote bind_quoted: [opts: opts] do
@data_layer Ash.DataLayer.Ets
@mix_ins Ash.DataLayer.Ets
@ets_private? Keyword.get(opts, :private?, false)
def ets_private?() do
@ets_private?
end
end
end
def before_compile_hook(_env) do
quote do
struct_fields =
@attributes
|> Enum.map(fn attr ->
{attr.name, nil}
end)
|> Enum.concat(Enum.map(@relationships, fn rel -> {rel.name, :not_loaded} end))
defstruct struct_fields
end
end
def private?(resource) do
resource.ets_private?()
end
defmodule Query do
defstruct [:resource, :limit, match_spec: {:_, :"$2"}, offset: 0]
end
def resource_to_query(resource), do: %Query{resource: resource}
def limit(query, limit, _), do: {:ok, %Query{query | limit: limit}}
def offset(query, offset, _), do: {:ok, %{query | offset: offset}}
def can_query_async?(_), do: false
def filter(query, :id, id, _resource) do
{:ok, %{query | match_spec: {id, :"$2"}}}
end
def filter(_query, _field, _value, _resource) do
{:error, "filter not supported for anything other than id"}
end
def run_query(
%Query{resource: resource, match_spec: match_spec, offset: offset, limit: limit},
_
) do
with {:ok, table} <- wrap_or_create_table(resource),
{:ok, {results, _}} <- match_limit(table, match_spec, limit, offset) do
ret = results |> Enum.drop(offset) |> Enum.map(&List.first/1)
{:ok, ret}
end
end
def create(_resource, _attributes, relationships) when relationships != %{} do
{:error, "#{inspect(__MODULE__)} does not support creating with relationships"}
end
def create(resource, attributes, _relationships) do
with {:ok, table} <- wrap_or_create_table(resource),
attrs <- Map.put_new_lazy(attributes, :id, &Ash.UUID.generate/0),
record <- struct(resource, attrs),
{:ok, _} <- Ets.Set.put(table, {attrs.id, record}) do
{:ok, record}
else
{:error, error} -> {:error, error}
end
end
defp match_limit(table, match_spec, limit, offset) do
# TODO: Fix this
# This is a hack :(
# Either implement cursor based pagination
# or find a way to skip in ETS
if limit do
Ets.Set.match(table, match_spec, limit + offset)
else
Ets.Set.match(table, match_spec)
end
end
defp wrap_or_create_table(resource) do
case Ets.Set.wrap_existing(resource) do
{:error, :table_not_found} ->
protection =
if private?(resource) do
:private
else
:public
end
Ets.Set.new(name: resource, protection: protection, ordered: true, read_concurrency: true)
{:ok, table} ->
{:ok, table}
{:error, other} ->
{:error, other}
end
end
end

View file

@ -26,8 +26,8 @@ defmodule Ash.DataLayer.Paginator do
def paginate(resource, _action, query, params) do
with %__MODULE__{limit: limit, offset: offset} = paginator <- paginator(params),
{:ok, query} <- Ash.Data.offset(query, offset, resource),
{:ok, query} <- Ash.Data.limit(query, limit, resource) do
{:ok, query} <- Ash.DataLayer.offset(query, offset, resource),
{:ok, query} <- Ash.DataLayer.limit(query, limit, resource) do
{:ok, %{paginator | query: query}}
else
{:error, error} -> {:error, error}

64
lib/ash/uuid.ex Normal file
View file

@ -0,0 +1,64 @@
defmodule Ash.UUID do
@moduledoc "UUID behaviour ripped directly from ecto."
@typedoc """
A hex-encoded UUID string.
"""
@type t :: <<_::288>>
@typedoc """
A raw binary representation of a UUID.
"""
@type raw :: <<_::128>>
@doc """
Generates a version 4 (random) UUID.
"""
@spec generate() :: t
def generate() do
{:ok, uuid} = encode(bingenerate())
uuid
end
@doc """
Generates a version 4 (random) UUID in the binary format.
"""
@spec bingenerate() :: raw
def bingenerate() do
<<u0::48, _::4, u1::12, _::2, u2::62>> = :crypto.strong_rand_bytes(16)
<<u0::48, 4::4, u1::12, 2::2, u2::62>>
end
defp encode(
<<a1::4, a2::4, a3::4, a4::4, a5::4, a6::4, a7::4, a8::4, b1::4, b2::4, b3::4, b4::4,
c1::4, c2::4, c3::4, c4::4, d1::4, d2::4, d3::4, d4::4, e1::4, e2::4, e3::4, e4::4,
e5::4, e6::4, e7::4, e8::4, e9::4, e10::4, e11::4, e12::4>>
) do
<<e(a1), e(a2), e(a3), e(a4), e(a5), e(a6), e(a7), e(a8), ?-, e(b1), e(b2), e(b3), e(b4), ?-,
e(c1), e(c2), e(c3), e(c4), ?-, e(d1), e(d2), e(d3), e(d4), ?-, e(e1), e(e2), e(e3), e(e4),
e(e5), e(e6), e(e7), e(e8), e(e9), e(e10), e(e11), e(e12)>>
catch
:error -> :error
else
encoded -> {:ok, encoded}
end
@compile {:inline, e: 1}
defp e(0), do: ?0
defp e(1), do: ?1
defp e(2), do: ?2
defp e(3), do: ?3
defp e(4), do: ?4
defp e(5), do: ?5
defp e(6), do: ?6
defp e(7), do: ?7
defp e(8), do: ?8
defp e(9), do: ?9
defp e(10), do: ?a
defp e(11), do: ?b
defp e(12), do: ?c
defp e(13), do: ?d
defp e(14), do: ?e
defp e(15), do: ?f
end

11
mix.exs
View file

@ -7,12 +7,21 @@ defmodule Ash.MixProject do
version: "0.1.0",
elixir: "~> 1.9",
start_permanent: Mix.env() == :prod,
elixirc_paths: elixirc_paths(Mix.env()),
deps: deps()
]
end
defp elixirc_paths(:test) do
["lib", "test/support"]
end
defp elixirc_paths(_), do: ["/lib"]
# Run "mix help deps" to learn about dependencies.
defp deps do
[]
[
{:ets, "~> 0.7.3", only: [:dev, :test]}
]
end
end

View file

@ -3,6 +3,7 @@
"dataloader": {:hex, :dataloader, "1.0.6", "fb724d6d3fb6acb87d27e3b32dea3a307936ad2d245faf9cf5221d1323d6a4ba", [:mix], [{:ecto, ">= 0.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"},
"db_connection": {:hex, :db_connection, "2.1.1", "a51e8a2ee54ef2ae6ec41a668c85787ed40cb8944928c191280fe34c15b76ae5", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"},
"decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm"},
"ets": {:hex, :ets, "0.7.3", "60862855af5ae89bb631c787ab9ba946509d59fa632442ef33947b18ac288101", [:mix], [], "hexpm"},
"jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
"plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"},

View file

@ -0,0 +1,49 @@
defmodule Ash.Test.Actions.Read do
use ExUnit.Case, async: true
alias Ash.Test.Post
describe "Ash.get/3" do
setup do
{:ok, post} = Ash.create(Post, %{attributes: %{title: "test", contents: "yeet"}})
%{post: post}
end
test "it returns a matching record", %{post: post} do
assert {:ok, fetched_post} = Ash.get(Post, post.id)
assert fetched_post == post
end
test "it returns nil when there is no matching record" do
assert {:ok, nil} = Ash.get(Post, Ash.UUID.generate())
end
end
describe "Ash.read/2 with no records" do
test "returns an empty result" do
assert {:ok, %{results: []}} = Ash.read(Post)
end
end
describe "Ash.read/2" do
setup do
{:ok, post1} = Ash.create(Post, %{attributes: %{title: "test", contents: "yeet"}})
{:ok, post2} = Ash.create(Post, %{attributes: %{title: "test1", contents: "yeet2"}})
%{post1: post1, post2: post2}
end
test "with page size of 1, returns only 1 record" do
assert {:ok, %{results: [_post]}} = Ash.read(Post, %{page: %{limit: 1}})
end
test "with page size of 2, returns 2 records" do
assert {:ok, %{results: [_, _]}} = Ash.read(Post, %{page: %{limit: 2}})
end
test "with page size of 1 and an offset of 1, it returns 1 record" do
assert {:ok, %{results: [_]}} = Ash.read(Post, %{page: %{limit: 1, offset: 1}})
end
end
end

View file

@ -1,8 +1,4 @@
defmodule AshTest do
use ExUnit.Case
doctest Ash
test "greets the world" do
assert Ash.hello() == :world
end
end

13
test/support/post.ex Normal file
View file

@ -0,0 +1,13 @@
defmodule Ash.Test.Post do
use Ash.Resource, name: "posts", type: "post"
use Ash.DataLayer.Ets, private: true
actions do
defaults [:read, :create]
end
attributes do
attribute :title, :string
attribute :contents, :string
end
end