property values as maps closes #53 #56
38
Justfile
|
|
@ -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
|
||||
|
|
||||
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
|
|
@ -0,0 +1,34 @@
|
|||
defmodule Mv.Membership.Email do
|
||||
@constraints [
|
||||
match: ~r/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/,
|
||||
|
rafael
commented
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 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
rafael
commented
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 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 🤔
moritz
commented
`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
|
||||
|
rafael
commented
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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
rafael
commented
We could make this more convenient by storing 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
rafael
commented
Adding a 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]
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "A0402269CB456075B81CA4CB3A2135A2C88D8B7FD51CD7A23084AA5264FEE344",
|
||||
"hash": "35D45214D6D344B0AF6CFCB69B8682FCB3D382D85883D3D3AAC1AEE7F54FD89A",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
Very nice 👌