From 4cabb8a838c0415ad61536d0823d36ea7e6db4fe Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Thu, 28 Nov 2019 00:24:29 -0500 Subject: [PATCH] add an ets data layer, and start on some testing --- README.md | 1 + config/config.exs | 7 ++ lib/ash.ex | 19 ++++- lib/ash/data.ex | 109 ---------------------------- lib/ash/data_layer/actions.ex | 109 +++++++++++++++++++++++++++- lib/ash/data_layer/data_layer.ex | 117 ++++++++++++++++++++++++++++++- lib/ash/data_layer/ets.ex | 112 +++++++++++++++++++++++++++++ lib/ash/data_layer/paginator.ex | 4 +- lib/ash/uuid.ex | 64 +++++++++++++++++ mix.exs | 11 ++- mix.lock | 1 + test/actions/read_test.exs | 49 +++++++++++++ test/ash_test.exs | 4 -- test/support/post.ex | 13 ++++ 14 files changed, 497 insertions(+), 123 deletions(-) delete mode 100644 lib/ash/data.ex create mode 100644 lib/ash/data_layer/ets.ex create mode 100644 lib/ash/uuid.ex create mode 100644 test/actions/read_test.exs create mode 100644 test/support/post.ex diff --git a/README.md b/README.md index 9d4b39ae..a69b47f9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/config/config.exs b/config/config.exs index d2d855e6..190e4ebc 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1 +1,8 @@ use Mix.Config + +if Mix.env() == :test do + config :ash, + resources: [ + Ash.Test.Post + ] +end diff --git a/lib/ash.ex b/lib/ash.ex index 630654ed..ae65b2c5 100644 --- a/lib/ash.ex +++ b/lib/ash.ex @@ -89,8 +89,23 @@ defmodule Ash do end def read(resource, params \\ %{}) do - action = Map.get(params, :action) || primary_action(resource, :read) - Ash.DataLayer.Actions.run_read_action(resource, action, params) + 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 diff --git a/lib/ash/data.ex b/lib/ash/data.ex deleted file mode 100644 index 83fd39fc..00000000 --- a/lib/ash/data.ex +++ /dev/null @@ -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 diff --git a/lib/ash/data_layer/actions.ex b/lib/ash/data_layer/actions.ex index 2a693054..dd7d56ad 100644 --- a/lib/ash/data_layer/actions.ex +++ b/lib/ash/data_layer/actions.ex @@ -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 diff --git a/lib/ash/data_layer/data_layer.ex b/lib/ash/data_layer/data_layer.ex index 5f5f760d..1faab560 100644 --- a/lib/ash/data_layer/data_layer.ex +++ b/lib/ash/data_layer/data_layer.ex @@ -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 diff --git a/lib/ash/data_layer/ets.ex b/lib/ash/data_layer/ets.ex new file mode 100644 index 00000000..023259c7 --- /dev/null +++ b/lib/ash/data_layer/ets.ex @@ -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 diff --git a/lib/ash/data_layer/paginator.ex b/lib/ash/data_layer/paginator.ex index 3a3caa90..554ee9a3 100644 --- a/lib/ash/data_layer/paginator.ex +++ b/lib/ash/data_layer/paginator.ex @@ -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} diff --git a/lib/ash/uuid.ex b/lib/ash/uuid.ex new file mode 100644 index 00000000..b3e987aa --- /dev/null +++ b/lib/ash/uuid.ex @@ -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 + <> = :crypto.strong_rand_bytes(16) + <> + end + + defp encode( + <> + ) do + <> + 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 diff --git a/mix.exs b/mix.exs index ed97ef1a..d0fe60e8 100644 --- a/mix.exs +++ b/mix.exs @@ -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 diff --git a/mix.lock b/mix.lock index 32cc958f..0a57015c 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, diff --git a/test/actions/read_test.exs b/test/actions/read_test.exs new file mode 100644 index 00000000..07693a92 --- /dev/null +++ b/test/actions/read_test.exs @@ -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 diff --git a/test/ash_test.exs b/test/ash_test.exs index cd97507f..e2abe84d 100644 --- a/test/ash_test.exs +++ b/test/ash_test.exs @@ -1,8 +1,4 @@ defmodule AshTest do use ExUnit.Case doctest Ash - - test "greets the world" do - assert Ash.hello() == :world - end end diff --git a/test/support/post.ex b/test/support/post.ex new file mode 100644 index 00000000..a7c06d9e --- /dev/null +++ b/test/support/post.ex @@ -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