Overview of AshPostgres.DataLayer
mainAshPostgres.DataLayer is a PostgreSQL data layer for Ash that leverages Ecto's PostgreSQL capabilities to manage data persistence and database interactions.repository·main·Indexed 41 days ago
https://github.com/ash-project/ash_postgresThe specialized PostgreSQL data layer for the Ash Framework, enabling Ash resources to persist data in a PostgreSQL database. It leverages Ecto's PostgreSQL capabilities to manage data persistence, migrations, and database interactions. Supports PostgreSQL version 13.0 and higher, providing features such as custom indexes, check constraints, tenant management, JSONB query fragments, and manual relationship implementations.
AshPostgres.DataLayer is a PostgreSQL data layer for Ash that leverages Ecto's PostgreSQL capabilities to manage data persistence and database interactions.AshPostgres is the PostgreSQL data layer for the Ash Framework. It provides the necessary integration to use PostgreSQL as the underlying database for Ash resources.
Requirement:
13.0AshPostgres is the primary PostgreSQL data layer for the Ash Framework. It is designed to be the default choice for Ash applications because it provides a full-featured implementation that combines Ash's declarative structure with the native power and flexibility of PostgreSQL.
Compatibility:
Aggregates in AshPostgres are translated directly into SQL, allowing them to be used efficiently in filter and sort operations.
When you define an aggregate (like count) in a resource, AshPostgres performs the calculation at the database level. You can also load aggregates on demand for existing records using Ash.load!/2, which issues minimal SQL to fetch only the required aggregate data.
# Defining an aggregate in a resource
aggregates do
count :total_tickets, :tickets
count :open_tickets, :tickets do
filter expr(status == :open)
end
end
# Using aggregates in a query
Helpdesk.Support.Representative
|> Ash.Query.filter(closed_tickets < 4)
|> Ash.Query.sort(closed_tickets: :desc)
|> Ash.read!()
# Loading aggregates on demand
representatives = Helpdesk.Support.read!(Helpdesk.Support.Representative)
Ash.load!(representatives, :open_tickets)Manual relationships can leverage Ecto's full power, such as using Recursive Common Table Expressions (CTEs) to handle hierarchical data (e.g., finding all employees under a manager).
Note: For hierarchical data in Postgres, it is highly recommended to use AshPostgres.Ltree instead of manual recursive CTEs if possible, as it is built-in and optimized.
When implementing a recursive manual relationship with AshPostgres:
load/3: Use recursive_cte_query to fetch all descendants in a single query, then group them by the parent ID to return the required map structure.ash_postgres_join/6: Use inner_lateral_join or left_lateral_join combined with a subquery containing the recursive CTE to allow the relationship to be joined efficiently.ash_postgres_subquery/4: Construct a subquery that uses the recursive CTE to filter the destination records based on the current_binding (the parent).When configuring foreign keys, you can choose between :nothing and :restrict:
:nothing (Default): Allows the foreign-key check to be deferred until later in the transaction. This is useful when you need to update/delete the destination row first and then the reference within the same transaction.:restrict: Performs an immediate check of the foreign-key constraint, preventing the update or deletion from proceeding if it violates integrity.Both options ultimately prevent invalid data due to Postgres's enforcement of referential integrity, but :nothing provides more flexibility during complex transactions.
# Use :nothing if you need to perform multi-step updates in a transaction
references do
reference :post, on_delete: :nothing
end
# Use :restrict to prevent any modification that violates the constraint immediately
references do
reference :post, on_delete: :restrict
endThe repo function in an Ash Resource is called multiple times during a single query execution. If the function returns a different repository on subsequent calls (e.g., by using Enum.random/1 directly), Ash will be unable to perform joins or lateral joins, resulting in an Ash.Error.Query.InvalidExpression.
To ensure stability, the function should use the process dictionary to memoize the chosen replica for the duration of a single unit of work. This ensures that repo(resource, :read) returns the same repository for all resources involved in a single query.
Best Practices:
Process.put/2 and Process.get/1 to lock in a replica for the current process.Phoenix.LiveView or GenServer, clear the memoized replica at the start of a unit of work (e.g., at the start of a callback) using Process.delete(:replica) so the process can pick a new replica for the next request. Never clear it while a query is actively being built.# Example of a stable replica selector
defmodule MyApp.Repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres
@replicas [
MyApp.Repo.Replica1,
MyApp.Repo.Replica2
]
def replica do
case Process.get(:replica) do
nil ->
replica = Enum.random(@replicas)
Process.put(:replica, replica)
replica
replica ->
replica
end
end
end
# To reset the replica choice for a new unit of work:
Process.delete(:replica)Calculations in AshPostgres are pushed down into SQL, similar to aggregates. While aggregates work on groups of records (relationships), calculations work on individual records.
Calculations can reference aggregates defined on the same resource, enabling complex logic (like percentages) to be executed entirely within the database. They can be used in filter, sort, and load operations.
# Defining a calculation that uses an aggregate
calculations do
calculate :percent_open, :float, expr(open_tickets / total_tickets)
end
# Using the calculation in a query
Helpdesk.Support.Representative
|> Ash.Query.filter(percent_open > 0.25)
|> Ash.Query.sort(:percent_open)
|> Ash.Query.load(:percent_open)
|> Ash.read!()Upserts in AshPostgres use PostgreSQL's INSERT ... ON CONFLICT mechanism, which is designed to arbitrate concurrent inserts of the same key.
Concurrency Behavior:
DO UPDATE branch. Both succeed, and the row reflects the last writer's values.Note on PostgreSQL 17+: AshPostgres continues to use INSERT ... ON CONFLICT rather than the newer MERGE statement. This is because MERGE evaluates conditions against a snapshot and does not wait for concurrent inserts, which can lead to unique constraint violations when two transactions both attempt the WHEN NOT MATCHED branch for the same key.
Every record returned from an upsert operation includes :upsert_action metadata, which tells you whether the row was newly created or an existing row was modified.
Possible values for :upsert_action:
:insert: The record was newly created.:update: An existing record was updated.:upsert_skipped: The record was not changed because the upsert_condition did not hold (only available if return_skipped_upsert?: true is passed).Note on Views: If you are upserting into a resource backed by a PostgreSQL view, you must declare view? true in the resource's postgres section. However, records returned from views do not carry the :upsert_action metadata.
post = Ash.create!(changeset, upsert?: true)
Ash.Resource.get_metadata(post, :upsert_action)
# => :insert | :updateFragments allow you to use arbitrary Postgres expressions in your queries when Ash's built-in expressions are insufficient. You can use fragments in calculations, migrations, and filters.
Important: Escaping Question Marks
In fragments, ? is a placeholder for an interpolated argument. If your SQL requires a literal ? (such as the JSONB key-exists operator ? or jsonb_path_exists filters), you must escape it as \?. Failure to do so will result in the error: fragment(...) expects extra arguments in the same amount of question marks in string.
# Simple expression
fragment("? / ?", points, count)
# Calling a function
fragment("repeat('hello', 4)")
# Using a subquery (last resort)
fragment("points > (SELECT SUM(points) FROM games WHERE user_id = ? AND id != ?)", user_id, id)
# In a calculation
calculations do
calculate :lower_name, :string, expr(
fragment("LOWER(?)", name)
)
end
# In a migration
create table(:managers, primary_key: false) do
add :id, :uuid, null: false, default: fragment("UUID_GENERATE_V4()"), primary_key: true
endWhen using Postgres operators that contain a literal question mark, such as JSONB key-existence checks, you must escape the question mark using \? to prevent Ash from treating it as an interpolation placeholder.
# JSONB key-exists: does `data` have key "foo"?
fragment("? \? 'foo'", data)
# jsonb_path_exists with `?` filter
fragment("jsonb_path_exists(plan_snapshot->'steps', '$ \? (@.role == "coach")')")
# Accessing JSONB fields with -> and ->>
fragment("?->>'name' = ?", data, ^name)