Current section

Files

Jump to
gen_object lib gen_object.ex
Raw

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, put, 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, :name) # "Alice"
# Update a single field
person = Person.put(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.put_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
quote do
use GenServer
require Inherit
Inherit.setup(unquote(__MODULE__), unquote(fields))
@doc false
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts)
end
defwithhold start_link: 0, start_link: 1
@doc false
def start(opts \\ []) do
GenServer.start(__MODULE__, opts)
end
defwithhold start: 0, start: 1
@doc false
def child_spec(arg) do
super(arg)
end
defwithhold child_spec: 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
defwithhold 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
defwithhold stop: 1
@doc false
def init(opts) do
pid = self()
{:ok, struct(__MODULE__, Keyword.put(opts, :pid, pid))}
end
defwithhold init: 1
end
end
@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 """
Retrieves the value of a specific field from an object.
Returns the current value of the specified field without retrieving the entire object struct.
This is more efficient when you only need a single field value.
## 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 retrieve
## Examples
person = Person.new(name: "Alice", age: 30)
# Get a specific field using the object struct
name = GenObject.get(person, :name)
# Returns: "Alice"
# Get a specific field using the PID directly
age = GenObject.get(person.pid, :age)
# Returns: 30
"""
def get(%{pid: pid}, field) when is_pid(pid) and is_atom(field) do
get(pid, field)
end
def get(pid, field) when is_pid(pid) and is_atom(field) do
GenServer.call(pid, {:get, field})
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.put(person, :age, 31)
# Returns: %Person{name: "Alice", age: 31, pid: #PID<...>}
# Update using the PID directly
updated_person = GenObject.put(person.pid, :name, "Alice Smith")
# Returns: %Person{name: "Alice Smith", age: 31, pid: #PID<...>}
"""
def put(%{pid: pid}, field, value) when is_pid(pid) and is_atom(field) do
put(pid, field, value)
end
def put(pid, field, value) when is_pid(pid) and is_atom(field) do
GenServer.call(pid, {:put, field, value})
end
defoverridable put: 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.put!(person, :age, 31)
# Async update using the PID directly
:ok = GenObject.put!(person.pid, :name, "Alice Smith")
# Verify the update was applied
updated_person = GenObject.get(person)
# Returns: %Person{name: "Alice Smith", age: 31, pid: #PID<...>}
"""
def put!(%{pid: pid}, field, value) when is_pid(pid) and is_atom(field) do
put!(pid, field, value)
end
def put!(pid, field, value) when is_pid(pid) and is_atom(field) do
GenServer.cast(pid, {:put, field, value})
end
defoverridable put!: 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.put_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.put_lazy(person.pid, :name, fn p ->
p.name <> " (" <> Integer.to_string(p.age) <> ")"
end)
# Returns: %Person{name: "Alice (30)", age: 30, pid: #PID<...>}
"""
def put_lazy(%{pid: pid}, field, func) when is_pid(pid) and is_atom(field) and is_function(func) do
put_lazy(pid, field, func)
end
def put_lazy(pid, field, func) when is_pid(pid) and is_atom(field) and is_function(func) do
GenServer.call(pid, {:put_lazy, field, func})
end
defoverridable put_lazy: 3
@doc """
Updates a specific field using a function asynchronously, returning `:ok` immediately.
This is the asynchronous version of `put_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.put_lazy!(person, :age, fn p -> p.age + 1 end)
# Async modify name based on current state
:ok = GenObject.put_lazy!(person.pid, :name, fn p ->
p.name <> " (" <> Integer.to_string(p.age) <> ")"
end)
# Verify the updates were applied
updated_person = GenObject.get(person)
"""
def put_lazy!(%{pid: pid}, field, func) when is_pid(pid) and is_atom(field) and is_function(func) do
put_lazy!(pid, field, func)
end
def put_lazy!(pid, field, func) when is_pid(pid) and is_atom(field) and is_function(func) do
GenServer.cast(pid, {:put_lazy, field, func})
end
defoverridable put_lazy!: 3
defp do_put(object, field, value) do
struct(object, %{field => value})
end
@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 `put/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
defp do_merge(object, fields) do
Map.merge(object, fields)
end
@doc false
def handle_call(:get, _from, object) do
{:reply, object, object}
end
def handle_call({:get, field}, _from, object) do
{:reply, Map.get(object, field), 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 = do_merge(object, fields)
{:reply, object, object}
end
def handle_call({:merge_lazy, func}, _from, object) when is_function(func) do
fields = func.(object)
object = do_merge(object, fields)
{:reply, object, object}
end
def handle_call({:put, field, value}, _from, object) do
object = do_put(object, field, value)
{:reply, object, object}
end
def handle_call({:put_lazy, field, func}, _from, object) when is_function(func) do
value = func.(object)
object = do_put(object, field, value)
{:reply, object, object}
end
def handle_call(msg, _from, object) do
Logger.warning("unhandled messge: #{msg}")
{:reply, :ok, object}
end
defoverridable handle_call: 3
@doc false
def handle_cast({:put, field, value}, object) do
object = do_put(object, field, value)
{:noreply, object}
end
def handle_cast({:put_lazy, field, func}, object) when is_function(func) do
value = func.(object)
object = do_put(object, field, value)
{:noreply, object}
end
def handle_cast({:merge, fields}, object) do
object = do_merge(object, fields)
{:noreply, object}
end
def handle_cast({:merge_lazy, func}, object) when is_function(func) do
fields = func.(object)
object = do_merge(object, fields)
{:noreply, object}
end
def handle_cast(msg, object) do
Logger.warning("unhandled message #{msg}")
{:noreply, object}
end
defoverridable handle_cast: 2
@doc false
def handle_info(msg, object) do
Logger.warning("unhandled message #{msg}")
{:noreply, object}
end
defoverridable handle_info: 2
end