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