Note: This library is under active development, unstable and the API may change.

Runtime-driven dynamic analysis for any Ash resource. Turn "give me a chart of X grouped by Y, filtered by Z" into a generic, safe, reusable runtime capability — instead of writing a bespoke aggregate action per chart.

AshDyan is a standalone Ash extension with no dependency on ash_phoenix_gen_api. It works on any Ash app, Phoenix or not. It is not a full BI/reporting engine, not a query builder UI, and not tied to Phoenix/Channels. Delivery (HTTP controller, Channel, LiveView, gen_api mfa) is a thin adapter on top.

Installation

def deps do
  [
    {:ash_dyan, "~> 0.1.0"}
  ]
end

Security model

The dyan DSL is a whitelist. The runtime request can only reference fields, functions, buckets, and filter targets declared there — this is what makes "arbitrary column + arbitrary filter from the client" safe rather than an injection/DoS vector. Queries run through the resource's normal read action, so Ash policies/authorization apply unchanged. There is no "skip policies" mode.

Declaring a resource analyzable

defmodule MyApp.Order do
  use Ash.Resource,
    extensions: [AshDyan]

  dyan do
    analyzable_field :status, type: :frequency
    analyzable_field :total_amount, type: :aggregate, functions: [:sum, :avg, :min, :max, :count, :count_distinct, :stddev, :variance, :median]
    analyzable_field :inserted_at, type: :time_bucket, buckets: [:day, :week, :month]
    analyzable_field :total_amount, type: :percentile, percentiles: [50, 90, 99]
    analyzable_field :total_amount, type: :histogram, bins: 10

    max_group_by 3
    default_limit 100
    max_limit 1000
    allow_filters_on [:status, :region, :inserted_at]
  end
end

A domain-level declaration is a thin registry for discovery (cross-resource joins are out of scope for v1):

defmodule MyApp.Shop do
  use Ash.Domain, extensions: [AshDyan.Domain]

  dyan do
    analyzable_resource MyApp.Order
  end
end

Runtime request

%{
  domain: MyApp.Shop,
  resource: MyApp.Order,
  type: :time_bucket,          # :frequency | :aggregate | :time_bucket | :percentile | :histogram
  column: :total_amount,
  function: :sum,               # required for :aggregate
  bucket: :day,                 # required for :time_bucket
  time_field: :inserted_at,
  group_by: [:status],          # optional, checked against max_group_by
  percentiles: [50, 90],        # required for :percentile
  bins: 10,                     # optional for :histogram (default 10)
  bin_width: nil,               # optional for :histogram (auto-computed if nil)
  filters: %{status: "paid", region: ["EU", "US"]},
  limit: 200
}

Run it:

{:ok, result} = AshDyan.run(spec)
# with an actor for policy checks:
{:ok, result} = AshDyan.run(spec, actor: current_user)
# with an explicit in-memory dataset (Ash.DataLayer.Simple / tests):
{:ok, result} = AshDyan.run(spec, data: rows)
# turn the result into a chart-library-ready shape:
chart = AshDyan.Charts.to_chartjs(result)

AshDyan.run/1 (or run/2 with an actor) is the single entry point. It:

  1. Validates the spec against the resource's dyan DSL config (unknown column/function → error naming the offending field, not silently ignored).
  2. Builds an Ash.Query selecting only the needed columns, applying the caller's filters and the configured limit.
  3. Runs it through the resource's normal read action — so Ash policies apply.
  4. Aggregates the result in memory into the stable chart shape.

Output shape

%AshDyan.Result{
  type: :time_bucket,
  labels: ["2026-07-01", "2026-07-02", ...],
  series: [
    %{name: "paid", data: [120.5, 98.0, ...]},
    %{name: "refunded", data: [12.0, 4.5, ...]}
  ]
}

Frequency and histogram outputs use the same labels/series shape so a client-side chart adapter doesn't need per-type branching.

How it works

AshDyan.run/1 is the single entry point. It:

  1. Validates the spec against the resource's dyan DSL config (unknown column/function → error naming the offending field, not silently ignored).
  2. Builds an Ash.Query that selects only the needed columns, applies the caller's filters (via filter_input, which honors field policies) and the configured limit.
  3. Runs it through the resource's normal read action — so Ash policies apply.
  4. Aggregates the returned rows in memory into the stable chart shape.

Why in-memory aggregation?

Ash's Ash.Query (3.x) does not expose a generic group_by builder, and the return shape of grouped aggregates is data-layer dependent. To keep AshDyan data-layer agnostic, safe, and predictable, the engine fetches only the columns it needs (bounded by max_limit, a hard cap that prevents a full-cardinality group_by from blowing up the DB) and aggregates in memory. This keeps the security boundary (the dyan DSL whitelist + enforced limits) intact while avoiding data-layer-specific query shapes. TimeBucket.expr/2 is provided as a reference for a future Postgres date_trunc pushdown.

Capability notes & data-layer limits

CapabilityApproachData-layer dependency
Frequency / group-byin-memory count after a filtered, limited readAny Ash data layer
Numeric aggregatesin-memory sum/avg/min/max/count/count_distinct/Any Ash data layer
stddev/variance/median after a filtered read
Time bucketingin-memory bucket label (Postgres date_trunc ref)Any Ash data layer
Percentilesin-memory percentile computationAny Ash data layer
Histogramin-memory binning of a numeric columnAny Ash data layer

All four capabilities therefore work on any Ash data layer. The capability check (AshDyan.supports?/2) still surfaces data-layer limits explicitly so callers can discover them before issuing a query — for example, a deployment that wants to forbid percentiles on the in-memory Ash.DataLayer.Simple layer can do so by configuring AshDyan.DataLayer.Simple to return false for :percentile.

Non-functional guarantees

  • Authorization: runs through the resource's read action; Ash policies apply.
  • Resource limits: max_group_by, max_limit, and a query_timeout are enforced. query_timeout is always applied to the underlying read (it defaults to the resource's configured query_timeout and can be overridden per call via run(spec, timeout: ms)).
  • Errors: validation errors name the offending field/function and carry a stable reason atom for programmatic matching (see AshDyan.Error).
  • Logging: AshDyan.run/2 emits structured Logger events — :debug when a request starts or is rejected, :warning when the analysis type is unsupported by the data layer, and :error when the read fails. Filter contents are never logged.
  • Testability: the engine is pure run/1,2 functions testable against Ash resources without any web layer.

Adapters (reference, not required)

Milestones

  • M0 — DSL scaffolding: dyan section/entities, Info module, verifiers.
  • M1 — frequency + numeric aggregates, formatter, tests (ETS + Postgres-ready).
  • M2 — time bucketing with Postgres date_trunc and ETS fallback.
  • M3 — percentiles/histograms with capability-check API.
  • M4 — hardening: limits, timeouts, structured errors.
  • M5 — docs & adapters (this file + the adapters above).