Current section
Files
Jump to
Current section
Files
lib/gen_object.ex
defmodule GenObject do
@moduledoc """
A library for creating stateful objects backed by GenServer processes with inheritance support.
GenObject provides a macro-based DSL for defining object-like structures that maintain
state in GenServer processes. Objects support field access, updates, lazy operations,
and merging. The library integrates with the [Inherit](https://github.com/DockYard/inherit)
library to provide inheritance modeling capabilities.
## Features
- **Stateful Objects**: Objects backed by GenServer processes with automatic lifecycle management
- **Field Operations**: get, set, and merge operations with both synchronous and asynchronous variants
- **Lazy Operations**: Functions that compute values based on current object state
- **Inheritance Support**: Integration with the Inherit library for object inheritance patterns
- **Process Safety**: All operations are process-safe through GenServer messaging
## Quick Start
defmodule Person do
use GenObject, [
name: "",
age: nil,
email: nil
]
end
# Create a new person object
person = Person.new(name: "Alice", age: 30)
# Access fields
Person.get(person, :first_name) # "Alice"
# Update a single field
person = Person.set(person, :age, 31)
# Update multiple fields
person = Person.merge(person, %{name: "Alice Smith", email: "alice@example.com"})
# Lazy updates based on current state
person = Person.set_lazy(person, :age, fn p -> p.age + 1 end)
## Inheritance with the Inherit Library
GenObject integrates seamlessly with the [Inherit](https://github.com/DockYard/inherit) library
to provide object inheritance patterns. The Inherit library allows you to define parent-child
relationships between objects and inherit fields and behaviors.
defmodule Animal do
use GenObject, [
name: "",
species: ""
]
end
defmodule Dog do
use Animal, [
breed: "",
trained: false
]
end
# Dog inherits all fields from Animal plus its own
dog = Dog.new(name: "Rex", species: "Canis lupus", breed: "Labrador")
"""
require Logger
use Inherit, [
pid: nil
]
@doc false
defmacro __using__(fields) do
before_callback = quote do
require Logger
use GenServer
end
quote do
require Inherit
Inherit.from(unquote(__MODULE__), unquote(fields), before: unquote(before_callback))
end
end
@doc false
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts)
end
defoverridable start_link: 0, start_link: 1
@doc false
def start(opts \\ []) do
GenServer.start(__MODULE__, opts)
end
defoverridable start: 0, start: 1
@doc """
Create a new object with the specified fields
"""
def new(opts \\ []) when is_list(opts) do
case start(opts) do
{:ok, pid} -> GenServer.call(pid, :get)
_other -> {:error, "could not start"}
end
end
defoverridable new: 0, new: 1
@doc """
Gracefully stops the curent object's GenServer
"""
def stop(%{pid: pid}) do
stop(pid)
end
def stop(pid) when is_pid(pid) do
GenServer.stop(pid)
end
defoverridable stop: 1
@doc false
def init(opts) do
pid = self()
{:ok, struct(__MODULE__, Keyword.put(opts, :pid, pid))}
end
defoverridable init: 1
@doc """
Retrieves the complete current state of an object.
Returns the full object struct containing all fields and their current values.
Accepts either a PID directly or a struct containing a `:pid` field.
## Parameters
- `pid_or_object` - The PID of the GenObject process, or an object struct containing a `:pid` field
## Examples
# Using the object struct
person = Person.new(name: "Alice", age: 30)
current_state = GenObject.get(person)
# Returns: %Person{name: "Alice", age: 30, pid: #PID<...>}
# Using the PID directly
current_state = GenObject.get(person.pid)
# Returns: %Person{name: "Alice", age: 30, pid: #PID<...>}
"""
def get(%{pid: pid}) when is_pid(pid) do
get(pid)
end
def get(pid) when is_pid(pid) do
GenServer.call(pid, :get)
end
defoverridable get: 1
@doc """
Handles getting a field value from an object.
This function is called whenever a field is accessed via `get/2`. Override this
function to implement virtual attributes that compute values dynamically instead
of storing them directly in the object state.
## Parameters
- `field` - The atom representing the field name being accessed
- `object` - The current object struct
## Returns
The value for the requested field. For regular fields, this returns the stored
value. For virtual attributes, this can return any computed value.
## Examples
# Define a virtual attribute that combines first and last name
def handle_get(:name, %Person{} = person) do
"\#{person.first_name} \#{person.last_name}"
end
# Fall back to default behavior for other fields
def handle_get(field, object) do
super(field, object)
end
"""
def handle_get(field, object) when is_atom(field) do
Map.get(object, field)
end
defoverridable handle_get: 2
@doc """
Retrieves the value of a specific field or multiple fields from an object.
When given a single field (atom), returns the current value of that field.
When given a list of fields, returns a list of values in the same order as requested.
This is more efficient than retrieving the entire object struct when you only need specific fields.
## Parameters
- `pid_or_object` - The PID of the GenObject process, or an object struct containing a `:pid` field
- `field_or_fields` - Either an atom representing a single field name, or a list of atoms for multiple fields
## Examples
person = Person.new(first_name: "Alice", last_name: "Smith", age: 30)
# Get a single field
first_name = Person.get(person, :first_name)
# Returns: "Alice"
# Get multiple fields at once
[first_name, age] = Person.get(person, [:first_name, :age])
# Returns: ["Alice", 30]
# Works with PIDs too
[last_name, age] = Person.get(person.pid, [:last_name, :age])
# Returns: ["Smith", 30]
# Virtual attributes work with lists too
[name, age] = Person.get(person, [:name, :age])
# Returns: ["Alice Smith", 30]
"""
def get(%{pid: pid}, field_or_fields) when is_pid(pid) and (is_atom(field_or_fields) or is_list(field_or_fields)) do
get(pid, field_or_fields)
end
def get(pid, field_or_fields) when is_pid(pid) and (is_atom(field_or_fields) or is_list(field_or_fields)) do
GenServer.call(pid, {:get, field_or_fields})
end
defoverridable get: 2
@doc """
Updates a specific field in an object and returns the updated object struct.
This is a synchronous operation that updates the field value and returns the complete
updated object struct. The operation is atomic and thread-safe.
## Parameters
- `pid_or_object` - The PID of the GenObject process, or an object struct containing a `:pid` field
- `field` - The atom representing the field name to update
- `value` - The new value to set for the field
## Examples
person = Person.new(name: "Alice", age: 30)
# Update using the object struct
updated_person = GenObject.set(person, :age, 31)
# Returns: %Person{name: "Alice", age: 31, pid: #PID<...>}
# Update using the PID directly
updated_person = GenObject.set(person.pid, :first_name, "Alice Smith")
# Returns: %Person{name: "Alice Smith", age: 31, pid: #PID<...>}
"""
def set(%{pid: pid}, field, value) when is_pid(pid) and is_atom(field) do
set(pid, field, value)
end
def set(pid, field, value) when is_pid(pid) and is_atom(field) do
GenServer.call(pid, {:set, field, value})
end
defoverridable set: 3
@doc """
Updates a specific field in an object asynchronously and returns `:ok` immediately.
This is an asynchronous operation that sends a cast message to update the field
and returns immediately without waiting for confirmation. Use this when you don't
need the updated object struct and want better performance.
## Parameters
- `pid_or_object` - The PID of the GenObject process, or an object struct containing a `:pid` field
- `field` - The atom representing the field name to update
- `value` - The new value to set for the field
## Examples
person = Person.new(name: "Alice", age: 30)
# Async update using the object struct
:ok = GenObject.set!(person, :age, 31)
# Async update using the PID directly
:ok = GenObject.set!(person.pid, :first_name, "Alice Smith")
# Verify the update was applied
updated_person = GenObject.get(person)
# Returns: %Person{name: "Alice Smith", age: 31, pid: #PID<...>}
"""
def set!(%{pid: pid}, field, value) when is_pid(pid) and is_atom(field) do
set!(pid, field, value)
end
def set!(pid, field, value) when is_pid(pid) and is_atom(field) do
GenServer.cast(pid, {:set, field, value})
end
defoverridable set!: 3
@doc """
Updates a specific field using a function that computes the new value based on current object state.
This synchronous operation allows you to update a field using a function that receives
the current object state and returns the new value for the field. Useful for updates
that depend on the current state of the object.
## Parameters
- `pid_or_object` - The PID of the GenObject process, or an object struct containing a `:pid` field
- `field` - The atom representing the field name to update
- `func` - A function that takes the current object struct and returns the new value for the field
## Examples
person = Person.new(name: "Alice", age: 30)
# Increment age based on current value
updated_person = GenObject.set_lazy(person, :age, fn p -> p.age + 1 end)
# Returns: %Person{name: "Alice", age: 31, pid: #PID<...>}
# Modify name based on current state
updated_person = GenObject.set_lazy(person.pid, :first_name, fn p ->
p.name <> " (" <> Integer.to_string(p.age) <> ")"
end)
# Returns: %Person{name: "Alice (30)", age: 30, pid: #PID<...>}
"""
def set_lazy(%{pid: pid}, field, func) when is_pid(pid) and is_atom(field) and is_function(func) do
set_lazy(pid, field, func)
end
def set_lazy(pid, field, func) when is_pid(pid) and is_atom(field) and is_function(func) do
GenServer.call(pid, {:set_lazy, field, func})
end
defoverridable set_lazy: 3
@doc """
Updates a specific field using a function asynchronously, returning `:ok` immediately.
This is the asynchronous version of `set_lazy/3`. It sends a cast message to update
the field using a function that computes the new value based on current object state,
but returns immediately without waiting for the operation to complete.
## Parameters
- `pid_or_object` - The PID of the GenObject process, or an object struct containing a `:pid` field
- `field` - The atom representing the field name to update
- `func` - A function that takes the current object struct and returns the new value for the field
## Examples
person = Person.new(name: "Alice", age: 30)
# Async increment age based on current value
:ok = GenObject.set_lazy!(person, :age, fn p -> p.age + 1 end)
# Async modify name based on current state
:ok = GenObject.set_lazy!(person.pid, :first_name, fn p ->
p.name <> " (" <> Integer.to_string(p.age) <> ")"
end)
# Verify the updates were applied
updated_person = GenObject.get(person)
"""
def set_lazy!(%{pid: pid}, field, func) when is_pid(pid) and is_atom(field) and is_function(func) do
set_lazy!(pid, field, func)
end
def set_lazy!(pid, field, func) when is_pid(pid) and is_atom(field) and is_function(func) do
GenServer.cast(pid, {:set_lazy, field, func})
end
defoverridable set_lazy!: 3
@doc """
Handles setting a field value in an object.
This function is called whenever a field is updated via `put/3`, `put!/3`, or their
lazy variants. Override this function to implement virtual attributes that can parse
or transform input values into multiple real fields.
## Parameters
- `pair` - A tuple of `{field, value}` representing the field name and new value
- `object` - The current object struct
## Returns
The updated object struct with the field changes applied.
## Examples
# Define a virtual attribute that splits a full name into parts
def handle_set({:name, full_name}, %Person{} = person) do
[first_name, last_name] = String.split(full_name, " ", parts: 2)
Map.merge(person, %{first_name: first_name, last_name: last_name})
end
# Fall back to default behavior for other fields
def handle_set(pair, object) do
super(pair, object)
end
"""
def handle_set({field, value} = pair, object) when is_atom(field) and is_tuple(pair) do
struct(object, %{field => value})
end
defoverridable handle_set: 2
@doc """
Merges multiple fields into an object and returns the updated object struct.
This synchronous operation updates multiple fields simultaneously, similar to `struct/2`
but for live GenObject processes. More efficient than multiple individual `set/3` calls
when updating several fields at once.
## Parameters
- `pid_or_object` - The PID of the GenObject process, or an object struct containing a `:pid` field
- `fields` - A map of field-value pairs to merge into the object
## Examples
person = Person.new(name: "Alice", age: 30)
# Merge multiple fields using the object struct
updated_person = GenObject.merge(person, %{
name: "Alice Smith",
age: 31,
email: "alice.smith@example.com"
})
# Returns: %Person{name: "Alice Smith", age: 31, email: "alice.smith@example.com", pid: #PID<...>}
# Merge using the PID directly
updated_person = GenObject.merge(person.pid, %{age: 32, location: "New York"})
"""
def merge(%{pid: pid}, fields) when is_pid(pid) and is_map(fields) do
merge(pid, fields)
end
def merge(pid, fields) when is_pid(pid) and is_map(fields) do
GenServer.call(pid, {:merge, fields})
end
defoverridable merge: 2
@doc """
Merges multiple fields into an object asynchronously, returning `:ok` immediately.
This is the asynchronous version of `merge/2`. It sends a cast message to update
multiple fields simultaneously but returns immediately without waiting for the
operation to complete. Use this for better performance when you don't need the updated struct.
## Parameters
- `pid_or_object` - The PID of the GenObject process, or an object struct containing a `:pid` field
- `fields` - A map of field-value pairs to merge into the object
## Examples
person = Person.new(name: "Alice", age: 30)
# Async merge multiple fields
:ok = GenObject.merge!(person, %{
name: "Alice Smith",
age: 31,
email: "alice.smith@example.com"
})
# Verify the updates were applied
updated_person = GenObject.get(person)
# Returns: %Person{name: "Alice Smith", age: 31, email: "alice.smith@example.com", pid: #PID<...>}
"""
def merge!(%{pid: pid}, fields) when is_pid(pid) and is_map(fields) do
merge!(pid, fields)
end
def merge!(pid, fields) when is_pid(pid) and is_map(fields) do
GenServer.cast(pid, {:merge, fields})
end
defoverridable merge!: 2
@doc """
Merges multiple fields using a function that computes values based on current object state.
This synchronous operation allows you to merge multiple fields using a function that
receives the current object state and returns a map of field-value pairs to merge.
Useful for complex updates that depend on multiple fields or computed values.
## Parameters
- `pid_or_object` - The PID of the GenObject process, or an object struct containing a `:pid` field
- `func` - A function that takes the current object struct and returns a map of field-value pairs to merge
## Examples
person = Person.new(name: "Alice", age: 30)
# Merge fields based on current state
updated_person = GenObject.merge_lazy(person, fn p ->
%{
name: p.name <> " Smith",
age: p.age + 1,
display_name: p.name <> " (" <> Integer.to_string(p.age + 1) <> ")"
}
end)
# Returns: %Person{name: "Alice Smith", age: 31, display_name: "Alice (31)", pid: #PID<...>}
# Complex computation based on multiple fields
updated_person = GenObject.merge_lazy(person.pid, fn p ->
age_group = if p.age < 18, do: "minor", else: "adult"
%{age_group: age_group, can_vote: p.age >= 18}
end)
"""
def merge_lazy(%{pid: pid}, func) when is_pid(pid) and is_function(func) do
merge_lazy(pid, func)
end
def merge_lazy(pid, func) when is_pid(pid) and is_function(func) do
GenServer.call(pid, {:merge_lazy, func})
end
defoverridable merge_lazy: 2
@doc """
Merges multiple fields using a function asynchronously, returning `:ok` immediately.
This is the asynchronous version of `merge_lazy/2`. It sends a cast message to merge
multiple fields using a function that computes values based on current object state,
but returns immediately without waiting for the operation to complete.
## Parameters
- `pid_or_object` - The PID of the GenObject process, or an object struct containing a `:pid` field
- `func` - A function that takes the current object struct and returns a map of field-value pairs to merge
## Examples
person = Person.new(name: "Alice", age: 30)
# Async merge fields based on current state
:ok = GenObject.merge_lazy!(person, fn p ->
%{
name: p.name <> " Smith",
age: p.age + 1,
display_name: p.name <> " (" <> Integer.to_string(p.age + 1) <> ")"
}
end)
# Verify the updates were applied
updated_person = GenObject.get(person)
# Returns: %Person{name: "Alice Smith", age: 31, display_name: "Alice (31)", pid: #PID<...>}
"""
def merge_lazy!(%{pid: pid}, func) when is_pid(pid) and is_function(func) do
merge_lazy!(pid, func)
end
def merge_lazy!(pid, func) when is_pid(pid) and is_function(func) do
GenServer.cast(pid, {:merge_lazy, func})
end
defoverridable merge_lazy!: 2
@doc """
Handles merging multiple field values into an object.
This function is called whenever multiple fields are updated via `merge/2`, `merge!/2`,
or their lazy variants. The default implementation processes each field individually
through `handle_set/2`, which means virtual attributes work automatically with merge
operations.
Override this function if you need custom logic for processing multiple fields together,
such as validating field combinations or applying transformations that depend on
multiple input values.
## Parameters
- `fields` - A map of field-value pairs to merge into the object
- `object` - The current object struct
## Returns
The updated object struct with all field changes applied.
## Examples
# Custom merge logic that validates address fields together
def handle_merge(%{street: _, city: _, zip: _} = address_fields, %Person{} = person) do
# Custom validation logic here
if valid_address?(address_fields) do
super(address_fields, person)
else
raise "Invalid address combination"
end
end
# Fall back to default behavior for other merges
def handle_merge(fields, object) do
super(fields, object)
end
"""
def handle_merge(fields, object) when is_map(fields) do
Enum.reduce(fields, object, fn({field, value}, object) ->
handle_set({field, value}, object)
end)
end
defoverridable handle_merge: 2
@doc false
def handle_call(:get, _from, object) do
{:reply, object, object}
end
def handle_call({:get, field}, _from, object) when is_atom(field) do
{:reply, handle_get(field, object), object}
end
def handle_call({:get, fields}, _from, object) when is_list(fields) do
{:reply, Enum.map(fields, &handle_get(&1, object)), object}
end
def handle_call({:assign, assigns}, _from, object) when is_map(assigns) do
object = struct(object, assigns: Map.merge(object.assigns, assigns))
{:reply, object.pid, object}
end
def handle_call({:merge, fields}, _from, object) do
object = handle_merge(fields, object)
{:reply, object, object}
end
def handle_call({:merge_lazy, func}, _from, object) when is_function(func) do
fields = func.(object)
object = handle_merge(fields, object)
{:reply, object, object}
end
def handle_call({:set, field, value}, _from, object) do
object = handle_set({field, value}, object)
{:reply, object, object}
end
def handle_call({:set_lazy, field, func}, _from, object) when is_function(func) do
value = func.(object)
object = handle_set({field, value}, object)
{:reply, object, object}
end
def handle_call(msg, _from, object) do
Logger.warning("#{inspect(self())} unhandled messge: #{inspect(msg)}")
{:reply, :ok, object}
end
defoverridable handle_call: 3
@doc false
def handle_cast({:set, field, value}, object) do
object = handle_set({field, value}, object)
{:noreply, object}
end
def handle_cast({:set_lazy, field, func}, object) when is_function(func) do
value = func.(object)
object = handle_set({field, value}, object)
{:noreply, object}
end
def handle_cast({:merge, fields}, object) do
object = handle_merge(fields, object)
{:noreply, object}
end
def handle_cast({:merge_lazy, func}, object) when is_function(func) do
fields = func.(object)
object = handle_merge(fields, object)
{:noreply, object}
end
def handle_cast(msg, object) do
Logger.warning("#{inspect(self())} unhandled message #{inspect(msg)}")
{:noreply, object}
end
defoverridable handle_cast: 2
@doc false
def handle_info(msg, object) do
Logger.warning("#{inspect(self())} unhandled message #{inspect(msg)}")
{:noreply, object}
end
defoverridable handle_info: 2
end