Skip to main content

Command Palette

Search for a command to run...

How to Integrate AshLtree with AshFrameWork

AshLtree in AshFrameWork: A Guide to Record Versioning

Published
4 min read
How to Integrate AshLtree with AshFrameWork

Introduction

Versioning records using ash_papertrail is straightforward enough, except when the record being versioned has children that also need versioning—and keeping them in sync with the parent version adds another layer of complexity. If you need to run analytics on versioned records, say goodbye to efficient queries because of the JSON and sometimes nested JSON storage. Enter AshLtree: it gives you the control needed to achieve the desired outcome with the freedom to orchestrate how versioning should occur.

Only one problem—the docs are scarce. In fact, this is the only resource I could find about AshLtree; nothing about setup, usage, or implementation. I pieced together the correct setup and usage from the Ash Discord support channel . Note: this guide is tailored for my use case.

AshLtree info

PostgreSQL supports a field type called ltree that enables efficient hierarchical queries. When you store a dot-separated string like 1.23.245.4 in an ltree field, PostgreSQL creates a tree-like structure representing the hierarchy: 1 (root parent) → 23 (child) → 245 (grandchild) → 4 (great-grandchild), and so on.

This allows you to traverse the tree in both directions—get all descendants of a parent, or find the root ancestor of any record.

Example Query

# Get all children and descendants under root parent '1'
YourResource
|> Ash.Query.filter(fragment("? <@ ?", path, "1"))
|> Ash.read!()

# Get all ancestors of a record
YourResource
|> Ash.Query.filter(fragment("? @> ?", path, "1.23.245.4"))
|> Ash.read!()

# Get the root parent of a deeply nested record
YourResource
|> Ash.Query.filter(fragment("? @> ?", path, "1.23.245.4"))
|> Ash.Query.filter(fragment("nlevel(?) = 1", path))
|> Ash.read!()

# Get immediate children matching a pattern
YourResource
|> Ash.Query.filter(fragment("? ~ ?", path, "1.23.*{1}"))
|> Ash.read!()

This limitation historically meant that you could not safely use standard UUIDs containing dashes (for example, 10b96ec7-dfd8-4653-a3a8-bc7fe5ef832f) when building hierarchical relationships with ltree. In PostgreSQL 15 and earlier, ltree labels only allowed alphanumeric characters and underscores, so hyphens were rejected outright. As a result, UUIDs in their canonical dashed format could not be stored as a single ltree label.

Starting with PostgreSQL 16, hyphens (-) are now allowed in ltree labels (in the C locale), which removes this restriction. However, the dot (.) is still treated as a path separator. This means that while dashed UUIDs are now syntactically valid as labels, a UUID must still appear as one label, not be split by dots, to avoid being interpreted as multiple tree levels.

In older PostgreSQL versions, the recommended solution was to strip the dashes from UUIDs before storing them in ltree paths. For example, instead of
10b96ec7-dfd8-4653-a3a8-bc7fe5ef832f,
you would store
10b96ec7dfd84653a3a8bc7fe5ef832f.

This approach remains a backward-compatible and version-safe strategy, especially if your application needs to support PostgreSQL versions prior to 16.

AshLtree Setup

In your migration file, you can add a field for the ltree path as follows:

defmodule YourApp.Repo.Migrations.AddAshLtree do
  use Ecto.Migration

  # excellent_migrations:safety-assured-for-this-file raw_sql_executed
  def up do
    execute("CREATE EXTENSION IF NOT EXISTS ltree")
  end
  def down, do: :ok

end
defmodule YourApp.Repo.Migrations.AddLtreePathToYourTable do
  use Ecto.Migration

  def change do
    alter table(:your_table_name) do
      add :path, :ltree
    end

    create index(:your_table_name, [:path], using: :gist)
  end
end

This setup will add a new ltree column named path to your specified table, allowing you to store hierarchical data efficiently. The index on the path The column will help optimize queries that involve hierarchical relationships. (A GIST index is a generalized search tree index in PostgreSQL that enables efficient querying of hierarchical data stored in ltree columns, supporting operations like ancestor/descendant lookups and path matching.) Ensure that AshLtree is included in the extensions List within your domain module.

Next, you update the AshResource to support the new path field, you need to modify the resource definition to include the ltree path.

efmodule YourApp.YourResource do
  use Ash.Resource

  attributes do
    attribute :path, AshPostgres.Ltree, allow_nil?: false
  end

  postgres do
    table "foo"
    repo YourApp.Repo
  end
end

To implement a hook or action that updates the path field in a way that AshLtree will understand, you can use a before_action hook. This hook will modify the path field before the action is executed.

...
@impl Ash.Resource.Change
  def change(changeset, _opts, _context) do
    parent_id = Ash.Changeset.get_attribute(changeset, :id)
    parent = Parent.get_parent!(parent_id)
    path =  parent.path ++ [child_id]  |> Enum.join(".")
    before_action(changeset, fn changeset ->
      Ash.Changeset.force_change_attribute(changeset, :path, path)
    end)
  end
...

Finally

With the foundational setup of AshLtree in place, you're now equipped to explore the vast possibilities of implementing versioning in your system. This setup not only allows for efficient hierarchical data management but also opens the door to sophisticated version control strategies. As you embark on this journey, consider how you can leverage these tools to maintain a comprehensive history of changes, ensuring that all related records are synchronized seamlessly. The path ahead is filled with opportunities to tailor the versioning process to your specific needs, enhancing both functionality and performance. Best of luck as you continue to innovate and refine your system. If you have any questions or need further guidance, feel free to reach out. Happy coding!