property values as maps closes #53 #56

Merged
moritz merged 5 commits from property_values into main 2025-05-29 15:34:24 +02:00
10 changed files with 152 additions and 32 deletions

View file

@ -1,4 +1,4 @@
run: install-dependencies start-database migrate-database
run: install-dependencies start-database migrate-database seed-database
mix phx.server
install-dependencies:
@ -10,6 +10,9 @@ migrate-database:
reset-database:
mix ash.reset
seed-database:
mix run priv/repo/seeds.exs
start-database:
docker compose up -d
@ -36,4 +39,35 @@ build-docker-container:
# This is meant for debugging the container build process only.
run-docker-container: build-docker-container
docker run -e "SECRET_KEY_BASE=ahK8BeiDaibaige1ahkooS0chie9lo7the7uuzar0eeBeeCh2iereteshee2Oosu" -e='DATABASE_URL=postgres://postgres@localhost:5432/mv_dev' -e='PORT=4040' -e='PHX_HOST=localhost' --network=host mitgliederverwaltung
docker run -e "SECRET_KEY_BASE=ahK8BeiDaibaige1ahkooS0chie9lo7the7uuzar0eeBeeCh2iereteshee2Oosu" -e='DATABASE_URL=postgres://postgres@localhost:5432/mv_dev' -e='PORT=4040' -e='PHX_HOST=localhost' --network=host mitgliederverwaltung
# Usage:
# just regen-migrations migration_name [commit_hash]
# If commit_hash is given, rollback & delete the migrations from that commit.
# Otherwise, rollback & delete all untracked migrations.
regen-migrations migration_name commit_hash='':
#!/usr/bin/env bash
set -euo pipefail
# Pick migrations either from the given commit or untracked files
if [ -n "{{commit_hash}}" ]; then

Very nice 👌

Very nice 👌
echo "→ Rolling back migrations from commit {{commit_hash}}"
MIG_FILES=$(git show --name-only --pretty=format: "{{commit_hash}}" \
| grep -E "^priv/repo/migrations/|^priv/resource_snapshots")
else
echo "→ Rolling back all untracked migrations"
MIG_FILES=$(git ls-files --others priv/repo/migrations)
fi
# Roll back in Ash
COUNT=$(echo "$MIG_FILES" | wc -l)
mix ash_postgres.rollback -n "$COUNT"
# Remove the migration files
echo removing $MIG_FILES
echo "$MIG_FILES" | xargs rm -f
# Also clean up any untracked resource snapshots
git ls-files --others priv/resource_snapshots | xargs rm -f
# Generate a fresh migration
mix ash.codegen --name "{{migration_name}}"

34
lib/membership/email.ex Normal file
View file

@ -0,0 +1,34 @@
defmodule Mv.Membership.Email do
@constraints [
match: ~r/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/,

For things like this, we can also integrate validations from ecto, with some duct-tape: By creating an ad-hoc ecto changeset and applying Ecto's validate_email function.

I don't think it's worth it to do this right now, but we should be aware of the opportunity to do this in the future, as Ecto's validations seem more battle-tested and feature-rich to me.

For things like this, we can also integrate [validations from ecto](https://hexdocs.pm/ecto_commons/EctoCommons.EmailValidator.html), with some duct-tape: By creating an ad-hoc ecto changeset and applying Ecto's `validate_email` function. I don't think it's worth it to do this right now, but we should be aware of the opportunity to do this in the future, as Ecto's validations seem more battle-tested and feature-rich to me.
trim?: true,
min_length: 5,
max_length: 254
]
use Ash.Type.NewType,
subtype_of: :string,
constraints: @constraints
@impl true
def cast_input(value, _) when is_binary(value) do
moritz marked this conversation as resolved Outdated

Why does this function get a binary value, is json stored as binary data in the DB? This is confusing to me because you use String functions with value below 🤔

Why does this function get a binary value, is json stored as binary data in the DB? This is confusing to me because you use String functions with `value` below 🤔
`value` is a string: https://hexdocs.pm/elixir/main/binaries-strings-and-charlists.html
value = if @constraints[:trim?], do: String.trim(value), else: value
cond do
@constraints[:min_length] && String.length(value) < @constraints[:min_length] ->
:error

Currently, this approach always shows an "is invalid" error message when validation fails. I think we should merge this as-is and move on as it's a minor detail, but in the future we could see if we can leverage ash's validations to provide more detailed error messages.

Currently, this approach always shows an "is invalid" error message when validation fails. I think we should merge this as-is and move on as it's a minor detail, but in the future we could see if we can leverage [ash's validations](https://hexdocs.pm/ash/validations.html) to provide more detailed error messages.
@constraints[:max_length] && String.length(value) > @constraints[:max_length] ->
:error
@constraints[:match] && !Regex.match?(@constraints[:match], value) ->
:error
true ->
{:ok, value}
end
end
@impl true
def cast_input(_, _), do: :error
end

View file

@ -16,8 +16,17 @@ defmodule Mv.Membership.Property do
attributes do
uuid_primary_key :id
attribute :value, :string,
description: "Speichert den Wert, Typ-Interpretation per property_type.typ"
attribute :value, :union,
constraints: [
storage: :type_and_value,
types: [
boolean: [type: :boolean],
date: [type: :date],
integer: [type: :integer],
string: [type: :string],
email: [type: Mv.Membership.Email]
]
]
end
relationships do
@ -25,4 +34,8 @@ defmodule Mv.Membership.Property do
belongs_to :property_type, Mv.Membership.PropertyType
end
calculations do
calculate :value_to_string, :string, expr(value[:value] <> "")
end
end

View file

@ -10,7 +10,7 @@ defmodule Mv.Membership.PropertyType do
actions do
defaults [:create, :read, :update, :destroy]
default_accept [:name, :type, :description, :immutable, :required]
default_accept [:name, :value_type, :description, :immutable, :required]
end
attributes do
@ -18,7 +18,8 @@ defmodule Mv.Membership.PropertyType do
attribute :name, :string, allow_nil?: false, public?: true
attribute :type, :string,
attribute :value_type, :atom,
constraints: [one_of: [:string, :integer, :boolean, :date, :email]],
allow_nil?: false,
description: "Definies the datatype `Property.value` is interpreted as"

View file

@ -9,7 +9,11 @@ defmodule MvWeb.MemberLive.FormComponent do
Enum.map(property_types, fn pt ->
%{
"property_type_id" => pt.id,
"value" => nil
"value" => %{
"type" => pt.value_type,
moritz marked this conversation as resolved Outdated

We could make this more convenient by storing PropertyType.type as an atom or an enum :)

https://hexdocs.pm/ash/Ash.Type.Atom.html

https://hexdocs.pm/ash/Ash.Type.Enum.html

We could make this more convenient by storing `PropertyType.type` as an atom or an enum :) https://hexdocs.pm/ash/Ash.Type.Atom.html https://hexdocs.pm/ash/Ash.Type.Enum.html
"value" => nil,
"_union_type" => Atom.to_string(pt.value_type)
}
}
end)
@ -34,7 +38,15 @@ defmodule MvWeb.MemberLive.FormComponent do
>
<.inputs_for :let={f_property} field={@form[:properties]}>
<% type = Enum.find(@property_types, &(&1.id == f_property[:property_type_id].value)) %>
<.input field={f_property[:value]} label={type && type.name} />
<.inputs_for :let={value_form} field={f_property[:value]}>
<% input_type =
moritz marked this conversation as resolved Outdated

Adding a type="checkbox" parameter for boolean properties should make booleans correctly show up in the update form here.

Adding a `type="checkbox"` parameter for boolean properties should make booleans correctly show up in the update form here.
cond do
type && type.value_type == :boolean -> "checkbox"
type && type.value_type == :date -> :date
true -> :text
end %>
<.input field={value_form[:value]} label={type && type.name} type={input_type} />
</.inputs_for>
<input
type="hidden"
name={f_property[:property_type_id].name}
@ -95,12 +107,27 @@ defmodule MvWeb.MemberLive.FormComponent do
not Enum.member?(existing_properties, Map.get(i, "property_type_id"))
end
params = %{
"properties" =>
Enum.map(member.properties, fn prop ->
%{
"property_type_id" => prop.property_type_id,
"value" => %{
"_union_type" => Atom.to_string(prop.value.type),
"type" => prop.value.type,
"value" => prop.value.value
}
}
end)
}
form =
AshPhoenix.Form.for_update(
member,
:update_member,
api: Mv.Membership,
as: "member",
params: params,
forms: [auto?: true]
)

View file

@ -11,7 +11,7 @@ defmodule Mv.Repo.Migrations.InitialMigration do
create table(:property_types, primary_key: false) do
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
add :name, :text, null: false
add :type, :text, null: false
add :value_type, :text, null: false
add :description, :text
add :immutable, :boolean, null: false, default: false
add :required, :boolean, null: false, default: false
@ -21,7 +21,7 @@ defmodule Mv.Repo.Migrations.InitialMigration do
create table(:properties, primary_key: false) do
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
add :value, :text
add :value, :map
add :member_id, :uuid
add :property_type_id, :uuid
end

View file

@ -2,38 +2,49 @@
#
# mix run priv/repo/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
# Mv.Repo.insert!(%Mv.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
alias Mv.Membership
for attrs <- [
%{
name: "Vorname",
type: "string",
value_type: :string,
description: "Vorname des Mitglieds",
immutable: true,
required: true
},
%{
name: "Nachname",
value_type: :string,
description: "Nachname des Mitglieds",
immutable: true,
required: true
},
%{
name: "Geburtsdatum",
value_type: :date,
description: "Geburtsdatum des Mitglieds",
immutable: true,
required: true
},
%{
name: "Bezahlt",
value_type: :boolean,
description: "Status des Mitgliedsbeitrages des Mitglieds",
immutable: true,
required: true
},
%{
name: "Email",
type: "string",
value_type: :email,
description: "Email-Adresse des Mitglieds",
immutable: true,
required: true
}
] do
# upsert?: true sorgt dafür, dass bei bestehendem Namen kein Fehler,
# sondern ein Update (hier effektiv No-Op) ausgeführt wird
{:ok, _} =
Membership.create_property_type(
attrs,
upsert?: true,
upsert_identity: :unique_name
)
Membership.create_property_type!(
attrs,
upsert?: true,
upsert_identity: :unique_name
)
end

View file

@ -16,7 +16,7 @@
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "A0402269CB456075B81CA4CB3A2135A2C88D8B7FD51CD7A23084AA5264FEE344",
"hash": "35D45214D6D344B0AF6CFCB69B8682FCB3D382D85883D3D3AAC1AEE7F54FD89A",
"identities": [],
"multitenancy": {
"attribute": null,

View file

@ -18,7 +18,7 @@
"references": null,
"size": null,
"source": "value",
"type": "text"
"type": "map"
},
{
"allow_nil?": true,
@ -84,7 +84,7 @@
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "F2A42A3427E0428637F465E4F357A3BE21B33231F94CF77B4843084128F6BDA5",
"hash": "8CF241CB9E8239511914EDEC96186BB7879529372BD8A4162431CCE9961F4F1B",
"identities": [],
"multitenancy": {
"attribute": null,

View file

@ -27,7 +27,7 @@
"primary_key?": false,
"references": null,
"size": null,
"source": "type",
"source": "value_type",
"type": "text"
},
{
@ -66,7 +66,7 @@
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "47210108DE1E7B2A20A67205E875B3440526941E61AB95B166976E8CD8AA0955",
"hash": "F98A723AE0D20005FBE4205E46ABEE09A88DFF9334C85BADC1FBEEF100F3E25B",
"identities": [
{
"all_tenants?": false,