Applying constraints to embedded union types

I have a resource when an embedded array of union types:
defmodule MyApi.Package
attributes do
...
attribute :options, {:array, PackageOption}, allow_nil?: false
end
end
defmodule MyApi.Package
attributes do
...
attribute :options, {:array, PackageOption}, allow_nil?: false
end
end
defmodule MyApi.PackageOption do
use Ash.Type.NewType,
subtype_of: :union,
constraints: [
types: [
boolean: [
type: MyApi.PackageOption.Boolean
tag: :type,
tag_value: :boolean
],
string: [
type: MyApi.PackageOption.String
tag: :type,
tag_value: :string
]
]
]
end

defmodule MyApi.PackageOption.Boolean do
use Ash.Resource, data_layer: :embedded

attributes do
uuid_primary_key :id
attribute :key, :ci_string, allow_nil?: false, constraints: [match: ~r/^[a-z0-9-]+$/]
attribute :value, :boolean
attribute :type, :atom, constraints: [one_of: [:boolean]]
attribute :enabled, :boolean, allow_nil?: false
end

identities do
identity :key, [:key]
end
end

defmodule MyApi.PackageOption.String do
use Ash.Resource, data_layer: :embedded

attributes do
uuid_primary_key :id
attribute :key, :ci_string, allow_nil?: false, constraints: [match: ~r/^[a-z0-9-]+$/]
attribute :value, :text
attribute :type, :atom, constraints: [one_of: [:string]]
attribute :enabled, :boolean, allow_nil?: false
end

identities do
identity :key, [:key]
end
end
defmodule MyApi.PackageOption do
use Ash.Type.NewType,
subtype_of: :union,
constraints: [
types: [
boolean: [
type: MyApi.PackageOption.Boolean
tag: :type,
tag_value: :boolean
],
string: [
type: MyApi.PackageOption.String
tag: :type,
tag_value: :string
]
]
]
end

defmodule MyApi.PackageOption.Boolean do
use Ash.Resource, data_layer: :embedded

attributes do
uuid_primary_key :id
attribute :key, :ci_string, allow_nil?: false, constraints: [match: ~r/^[a-z0-9-]+$/]
attribute :value, :boolean
attribute :type, :atom, constraints: [one_of: [:boolean]]
attribute :enabled, :boolean, allow_nil?: false
end

identities do
identity :key, [:key]
end
end

defmodule MyApi.PackageOption.String do
use Ash.Resource, data_layer: :embedded

attributes do
uuid_primary_key :id
attribute :key, :ci_string, allow_nil?: false, constraints: [match: ~r/^[a-z0-9-]+$/]
attribute :value, :text
attribute :type, :atom, constraints: [one_of: [:string]]
attribute :enabled, :boolean, allow_nil?: false
end

identities do
identity :key, [:key]
end
end
When doing a create or update on the parent (MyApi.Package) with an option that has an invalid key (see constraint), it's not returning %Ash.Error.Invalid{} as expected. I've tried applying a validation in lieu of an attribute constraint, but the validation is never called. Additionally, the identity is not enforced.
9 Replies
ZachDaniel
ZachDaniel2y ago
So if you provide a record with an invalid key what happens? Also yeah I think you're going to need to validate the uniqueness yourself or we're going to need to add explicit support for unique checking unions if they all have a key and all have an identity on it
Robert Graff
Robert GraffOP2y ago
Package.update(
package,
%{
options: [
%Ash.Union{
type: :boolean,
value: %PackageOption.Boolean{
type: :boolean,
value: false,
key: "not a valid key",
enabled: true
}
}
]
}
)

{:ok, %MyApi.Package{
...
options: [
%Ash.Union{
value: #PackageOption.Boolean<__lateral_join_source__: nil, __meta__: #Ecto.Schema.Metadata<:built, "">, id: "e8668815-061c-4f86-bfa7-4d166fd1dba2", key: "true!", value: true, type: :boolean, enabled: true, aggregates: %{}, calculations: %{}, __order__: nil, ...>,
type: :boolean
}
]
}}
Package.update(
package,
%{
options: [
%Ash.Union{
type: :boolean,
value: %PackageOption.Boolean{
type: :boolean,
value: false,
key: "not a valid key",
enabled: true
}
}
]
}
)

{:ok, %MyApi.Package{
...
options: [
%Ash.Union{
value: #PackageOption.Boolean<__lateral_join_source__: nil, __meta__: #Ecto.Schema.Metadata<:built, "">, id: "e8668815-061c-4f86-bfa7-4d166fd1dba2", key: "true!", value: true, type: :boolean, enabled: true, aggregates: %{}, calculations: %{}, __order__: nil, ...>,
type: :boolean
}
]
}}
ZachDaniel
ZachDaniel2y ago
Hm….something seems strange there Oh You’re providing a structure there It assumes you got structs by casting them To test the casting you’d want to provide something like [%{key: “not a valid key”, type: :boolean, value: false}]
Robert Graff
Robert GraffOP2y ago
Wow, ok. Using a map instead of a struct, the constraints and the validations are applied. Are the validations supposed to be skipped if there's no casting?
ZachDaniel
ZachDaniel2y ago
Yeah, the idea is that you provided an already valid value if providing structs (because the outside world can’t provide a struct, like over an api)
Robert Graff
Robert GraffOP2y ago
Got it. That makes sense now. For other reasons, I've not been able to get forms working and I haven't tested the apis
ZachDaniel
ZachDaniel2y ago
Yeah, forms against unions are currently something the framework doesn’t handle for you. We can, just need to add code to ash_phoenix And so what you end up needing to do is essentially roll your own, switching the form behavior based on the union type.
Robert Graff
Robert GraffOP2y ago
I can switch the behaviour for the union type pretty easily, but I'm not sure I'm adding forms correctly. I'm trying to get it working with just one type to start.
create_feature_form =
AshPhoenix.Form.for_create(Package, :create,
api: MyApi,
forms: [
variants: [
type: :list,
resource: PackageOption.Boolean, # or is it PackageOption Union?
create_action: :create
]
]
)
|> to_form()
create_feature_form =
AshPhoenix.Form.for_create(Package, :create,
api: MyApi,
forms: [
variants: [
type: :list,
resource: PackageOption.Boolean, # or is it PackageOption Union?
create_action: :create
]
]
)
|> to_form()
[error] GenServer #PID<0.1349.0> terminating
** (UndefinedFunctionError) function Kickplan.Environments.FeatureVariant.__struct__/0 is undefined or private
(kickplan 0.1.0) Kickplan.Environments.FeatureVariant.__struct__()
[error] GenServer #PID<0.1349.0> terminating
** (UndefinedFunctionError) function Kickplan.Environments.FeatureVariant.__struct__/0 is undefined or private
(kickplan 0.1.0) Kickplan.Environments.FeatureVariant.__struct__()
The union raises the struct error, but adding the form for one of the variants leads to other errors
ZachDaniel
ZachDaniel2y ago
I think what you might need to do is do it without auto forms entirely and manage the nested forms yourself. That’s without us adding special support for it in ash_phoenix. I’m currently trying to think of a better way, and/or how we’d be able to support a dynamic config for nested forms. I’ll have to tackle it when I’m back from vacation. In the meantime, you should be able to “roll your own” here by looping over the current values and storing a list of forms in the socket assigns, and handling validation that way. Actually if you just don’t add a form config for it, you should just be able to loop over the field value. And create a new form on the fly

Did you find this page helpful?