improvement(Ash.Type.Module): Add :module type. (#578)

This commit is contained in:
James Harton 2023-05-15 23:26:52 +12:00 committed by GitHub
parent ccacfd78fc
commit 7326ca330e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 308 additions and 1 deletions

155
lib/ash/type/module.ex Normal file
View file

@ -0,0 +1,155 @@
defmodule Ash.Type.Module do
@constraints [
behaviour: [
type: :atom,
doc: "Allows constraining the module a one which implements a behaviour"
],
protocol: [
type: :atom,
doc: "Allows constraining the module a one which implements a protocol"
]
]
@moduledoc """
Stores a module as a string in the database.
A builtin type that can be referenced via `:module`.
### Constraints
#{Spark.OptionsHelpers.docs(@constraints)}
"""
use Ash.Type
@impl true
def storage_type, do: :string
@impl true
def constraints, do: @constraints
def apply_constraints(nil, _), do: :ok
def apply_constraints(value, constraints) do
[]
|> apply_behaviour_constraint(value, constraints[:behaviour])
|> apply_protocol_constraint(value, constraints[:protocol])
|> case do
[] -> {:ok, value}
errors -> {:error, errors}
end
end
defp apply_behaviour_constraint(errors, _module, nil), do: errors
defp apply_behaviour_constraint(errors, module, behaviour) do
if Spark.implements_behaviour?(module, behaviour) do
errors
else
Enum.concat(errors, [
[
message: "module %{module} does not implement the %{behaviour} behaviour",
module: module,
behaviour: behaviour
]
])
end
end
defp apply_protocol_constraint(errors, _module, nil), do: errors
defp apply_protocol_constraint(errors, module, protocol) do
Protocol.assert_protocol!(protocol)
Protocol.assert_impl!(protocol, module)
errors
rescue
ArgumentError ->
Enum.concat(errors, [
[
message: "module %{module} does not implement the %{protocol} protocol",
module: module,
protocol: protocol
]
])
end
@impl true
def cast_input(value, _) when is_atom(value) do
if Code.ensure_loaded?(value) do
{:ok, value}
else
:error
end
end
def cast_input("", _), do: {:ok, nil}
def cast_input("Elixir." <> _ = value, _) do
module = Module.concat([value])
if Code.ensure_loaded?(module) do
{:ok, module}
else
:error
end
end
def cast_input(value, _) when is_binary(value) do
atom = String.to_existing_atom(value)
if Code.ensure_loaded?(atom) do
{:ok, atom}
else
:error
end
rescue
ArgumentError ->
:error
end
def cast_input(_, _), do: :error
@impl true
def cast_stored(nil, _), do: {:ok, nil}
def cast_stored(value, _) when is_atom(value) do
if Code.ensure_loaded?(value) do
{:ok, value}
else
:error
end
end
def cast_stored("Elixir." <> _ = value, _) do
module = Module.concat([value])
if Code.ensure_loaded?(module) do
{:ok, module}
else
:error
end
end
def cast_stored(value, _) when is_binary(value) do
atom = String.to_existing_atom(value)
if Code.ensure_loaded?(atom) do
{:ok, atom}
else
:error
end
rescue
ArgumentError -> :error
end
def cast_stored(_, _), do: :error
@impl true
def dump_to_native(nil, _), do: {:ok, nil}
def dump_to_native(value, _) when is_atom(value) do
{:ok, to_string(value)}
end
def dump_to_native(_, _), do: :error
end

View file

@ -45,7 +45,8 @@ defmodule Ash.Type do
utc_datetime: "Ash.Type.UtcDatetime",
utc_datetime_usec: "Ash.Type.UtcDatetimeUsec",
url_encoded_binary: "Ash.Type.UrlEncodedBinary",
union: "Ash.Type.Union"
union: "Ash.Type.Union",
module: "Ash.Type.Module"
]
|> Enum.map(fn {key, value} ->
{key, Module.concat([value])}

151
test/type/module_test.exs Normal file
View file

@ -0,0 +1,151 @@
defmodule Ash.Test.Type.ModuleTest do
@moduledoc false
use ExUnit.Case, async: true
import Ash.Changeset
require Ash.Query
defmodule StinkyBehaviour do
@moduledoc false
@callback stinky? :: boolean
end
defmodule StinkyModule do
@moduledoc false
@behaviour StinkyBehaviour
def stinky?, do: true
end
defprotocol StinkyProtocol do
@moduledoc false
def stinky?(_)
end
defmodule StinkyStruct do
@moduledoc false
defstruct stinky?: true
defimpl StinkyProtocol do
def stinky?(stinky), do: stinky.stinky?
end
end
defmodule GenericModule do
@moduledoc false
end
defmodule ModuleAttr do
@moduledoc false
use Ash.Resource, data_layer: Ash.DataLayer.Ets
ets do
private? true
end
actions do
defaults [:create, :read, :update, :destroy]
end
attributes do
uuid_primary_key :id
attribute :module, :module
end
end
defmodule ModuleAttrWithBehaviourConstraint do
@moduledoc false
use Ash.Resource, data_layer: Ash.DataLayer.Ets
ets do
private? true
end
actions do
defaults [:create, :read, :update, :destroy]
end
attributes do
uuid_primary_key :id
attribute :module, :module do
constraints behaviour: StinkyBehaviour
end
end
end
defmodule ModuleAttrWithProtocolConstraint do
@moduledoc false
use Ash.Resource, data_layer: Ash.DataLayer.Ets
ets do
private? true
end
actions do
defaults [:create, :read, :update, :destroy]
end
attributes do
uuid_primary_key :id
attribute :module, :module do
constraints protocol: StinkyProtocol
end
end
end
defmodule Registry do
@moduledoc false
use Ash.Registry
entries do
entry ModuleAttr
entry ModuleAttrWithBehaviourConstraint
entry ModuleAttrWithProtocolConstraint
end
end
defmodule Api do
@moduledoc false
use Ash.Api
resources do
registry Registry
end
end
test "module attribute with no constraints" do
ModuleAttr
|> new(%{module: GenericModule})
|> Api.create!()
end
test "module attribute with behaviour constraint when the module complies" do
ModuleAttrWithBehaviourConstraint
|> new(%{module: StinkyModule})
|> Api.create!()
end
test "module attribute with behaviour constraint when the module is not compliant" do
assert_raise Ash.Error.Invalid, fn ->
ModuleAttrWithBehaviourConstraint
|> new(%{module: GenericModule})
|> Api.create!()
end
end
test "module attribute with protocol constraint when the module complies" do
ModuleAttrWithProtocolConstraint
|> new(%{module: StinkyStruct})
|> Api.create!()
end
test "module attribute with protocol constraint when the module is not compliant" do
assert_raise Ash.Error.Invalid, fn ->
ModuleAttrWithProtocolConstraint
|> new(%{module: GenericModule})
|> Api.create!()
end
end
end