TL;DR: How do you practically use the embedded schemas when implementing single table inheritance in a Phoenix application?
There are several topics on STI here and I’m pretty sure I’ve read them all, so please bare with me on this one
My users’ domain requires strict management of entities and their relationships. I’m therefore looking into modeling the entities part on the “entity engine” of Apache Open For Business (OfBiz).
OfBiz is an enterprise resource planning (ERP) software written in Java. Crucially, it offers a comprehensive, general, off the shelf, data model to build on. I believe that the “entity engine” part of that may, at least, offer a reasonable point of departure for my users’ needs.
Note that OfBiz’s “entity engine” includes XML-based schema definitions, and much more, that is of no interest to my use case.
Basically, there is a finite set of models and fields one could reasonably imagine when it comes to entities:
- natural persons and
- legal persons.
So, I’m creating an Entity
schema tied to a corresponding table, together with embedded schemas NaturalPerson
and LegalPerson
following the Darren Wilson presentation at ElixirConf 2017 (see resources, references below).
The considered domain
What is particularly relevant to my application is that the domain of the industry (consultancy) centers around the concept of a Customer
where:
- A
Customer
has one or moreEntity
:s, - Each
Entity
may be either aNaturalPerson
(a human) or, by definition, aLegalPerson
(a corporation, trust etc.) and - An
Entity
belongs to one or moreCustomers
.
It’s inconceivable there would ever be any other types of entities than natural or legal persons.
No, I refuse to consider adding a third kind of entity anticipating the singularity!
A common use case for my users is to have “customers” in constellations such as:
- A natural person and their fully owned corporation
- Two natural persons
- Two natural persons and their jointly owned corporation
- A corporation and its subsidiaries
A new “customer” must be set up when, at some later point, constellations change. For instance, a customer doing business primarily through their fully owned corporation may sell their business but continue doing business personally.
Schemas
This is the data model.
Entity
defmodule MyApp.EntityEngine.Entity do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "entities" do
field :name1, :string
field :name2, :string
field :name3, :string
field :start_date, :date
field :end_date, :date
field :entity_type, Ecto.Enum, values: [:natural, :legal]
timestamps()
end
@doc false
def changeset(entity, attrs) do
entity
|> cast(attrs, [:name1, :name2, :name3, :start_date, :end_date, :entity_type])
|> validate_required([:name1, :entity_type])
end
end
NaturalPerson
defmodule MyAppp.EntityEngine.NaturalPerson do
use Ecto.Schema
import Ecto.Changeset
alias MyApp.EntityEngine.Entity
@primary_key false
embedded_schema do
field :first_name, :string
field :middle_name, :string
field :last_name, :string
field :birth_date, :date
field :death_date, :date
timestamps()
end
@doc false
def changeset(natural_person, attrs \\ %{}) do
natural_person
|> cast(attrs, [:first_name, :middle_name, :last_name, :birth_date, :death_date])
|> validate_required([:first_name, :last_name])
end
def to_entity(%__MODULE__{} = natural_person) do
%Entity{
name1: natural_person.first_name,
name2: natural_person.middle_name,
name3: natural_person.last_name,
start_date: natural_person.birth_date,
end_date: natural_person.death_date,
entity_type: :natural
}
end
end
LegalPerson
defmodule MyApp.EntityEngine.LegalPerson do
use Ecto.Schema
import Ecto.Changeset
alias MyApp.EntityEngine.Entity
@primary_key false
embedded_schema do
field :name, :string
field :established_date, :date
field :closed_date, :date
timestamps()
end
@doc false
def changeset(legal_person, attrs \\ %{}) do
legal_person
|> cast(attrs, [:name, :established_date, :closed_date])
|> validate_required([:name])
end
def to_entity(%__MODULE__{}, legal_person) do
%Entity{
name1: legal_person.name,
start_date: legal_person.established_date,
end_date: legal_person.closed_date,
entity_type: :legal
}
end
end
Practical use?
I’ve tried to approach this by adapting the generated Phoenix LiveView code – with some rather great frustrations along the way.
- How would you approach using these schemas when creating lists, detail views and forms?
Creating a new NaturalPerson
I’ve set up functions in my context like this:
def create_natural_person(attrs \\ %{}) do
%NaturalPerson{}
|> NaturalPerson.changeset(attrs)
|> Ecto.Changeset.apply_changes()
|> NaturalPerson.to_entity()
|> Repo.insert()
end
The whole idea is of course to have separate form components for natural and legal persons. You would have separate changesets for these obviously. When updating an entity, you will be faced with an Entity
from the database and would therefore need to “cast” this to the appropriate changeset for the embedded schema. I guess that one could run in to issues there? Then that has to go back into the database through the Entity
schema again.
I guess one would set up separate routes for natural and legal persons with their own forms in live components using their respective changesets and casting from and to the Entity
changeset?
Maybe you can tell that I’m stuck between the generators and just doing it myself in the best way possible.
Some pointers would be amazingly helpful here.
I feel stupid asking these questions
Hey, I’m recovering from a terrible condition that left me handicapped in many ways. I simply don’t have the energy to take things in and excel like I used to. I’m only now starting to be able to handle “stuff” and it’s actually going a lot better.
Now – this is what I’m afraid of when I post, but I know that this community is sooo amazing that my worries are unfounded Really, it’s different here!
I came for the BEAM but stayed for the community.
Darren Wilson’s ElixirConf 2017, regarding embedded_schema
As promised and for reference:
- Darren’s slides (corrected) as a PDF file, see page 121 and forward