DSL and Extension Architecture
This guide explains how the AshReports declarative DSL works, including the Spark integration, entity definitions, transformers, and verifiers.
Table of Contents
- Spark DSL Integration
- DSL Entities
- Extension Architecture
- Transformers
- Verifiers
- Info Module and Introspection
Spark DSL Integration
AshReports uses the Spark framework to provide a declarative DSL. Spark enables:
- Compile-time macro expansion
- Entity and section definitions
- Automatic validation
- Introspection capabilities
- Module generation
Extension Definition
The main extension is defined in lib/ash_reports.ex:
defmodule AshReports do
@reports_section AshReports.Dsl.reports_section()
use Spark.Dsl.Extension,
sections: [@reports_section],
transformers: [
AshReports.Transformers.BuildReportModules
],
verifiers: [
AshReports.Verifiers.ValidateReports,
AshReports.Verifiers.ValidateBands,
AshReports.Verifiers.ValidateElements
]
endDomain Extension
AshReports.Domain provides the same capabilities for Ash domains:
defmodule AshReports.Domain do
use Spark.Dsl.Extension,
sections: [@reports_section],
transformers: [AshReports.Transformers.BuildReportModules],
verifiers: [
AshReports.Verifiers.ValidateReports,
AshReports.Verifiers.ValidateBands,
AshReports.Verifiers.ValidateElements
]
endUsage:
defmodule MyApp.Reporting do
use Ash.Domain,
extensions: [AshReports.Domain]
reports do
report :sales_report do
# ...
end
end
endDSL Entities
The DSL is defined in lib/ash_reports/dsl.ex (~2,652 lines). Here's the complete entity hierarchy:
graph TD
RS[reports section]
RS --> R[report entity]
RS --> BC[bar_chart entity]
RS --> LC[line_chart entity]
RS --> PC[pie_chart entity]
RS --> AC[area_chart entity]
RS --> SC[scatter_chart entity]
RS --> GC[gantt_chart entity]
RS --> SL[sparkline entity]
R --> P[parameters section]
R --> B[bands section]
R --> V[variables section]
R --> G[groups section]
R --> FS[format_specs section]
P --> Param[parameter entity]
B --> Band[band entity]
V --> Var[variable entity]
G --> Grp[group entity]
FS --> Fmt[format_spec entity]
Band --> E[elements section]
Band --> SubB[nested bands]
Band --> Grid[grids section]
Band --> Tbl[tables section]
Band --> Stk[stacks section]
E --> Label[label]
E --> Field[field]
E --> Expr[expression]
E --> Agg[aggregate]
E --> Line[line]
E --> Box[box]
E --> Img[image]
E --> Charts[chart elements]Report Entity
The top-level report definition:
@report_entity %Spark.Dsl.Entity{
name: :report,
args: [:name],
target: AshReports.Report,
schema: [
name: [type: :atom, required: true, doc: "Unique report identifier"],
title: [type: :string, doc: "Display title"],
description: [type: :string, doc: "Report description"],
driving_resource: [type: :atom, required: true, doc: "Primary Ash resource"],
base_filter: [type: {:fun, 1}, doc: "Base query filter function"],
formats: [type: {:list, :atom}, default: [:html, :pdf, :json]],
permissions: [type: {:list, :atom}, default: []],
page_size: [type: :atom, default: :a4],
orientation: [type: :atom, default: :portrait]
],
entities: [
parameters: [@parameter_entity],
bands: [@band_entity],
variables: [@variable_entity],
groups: [@group_entity],
format_specs: [@format_spec_entity]
]
}Parameter Entity
Runtime parameters with validation:
@parameter_entity %Spark.Dsl.Entity{
name: :parameter,
args: [:name, :type],
target: AshReports.Parameter,
schema: [
name: [type: :atom, required: true],
type: [type: :atom, required: true], # :string, :integer, :date, :datetime, etc.
required: [type: :boolean, default: false],
default: [type: :any],
constraints: [type: :keyword_list, default: []],
description: [type: :string]
]
}Example:
parameter :start_date, :date do
required true
description "Report start date"
end
parameter :limit, :integer do
default 100
constraints min: 1, max: 1000
endBand Entity
Hierarchical report sections:
@band_entity %Spark.Dsl.Entity{
name: :band,
args: [:name],
target: AshReports.Band,
recursive_as: :bands, # Allows nested bands
schema: [
name: [type: :atom, required: true],
type: [
type: {:one_of, [
:title, :page_header, :column_header, :group_header,
:detail, :group_footer, :page_footer, :summary,
:background, :watermark, :no_data
]},
required: true
],
height: [type: :pos_integer],
visible: [type: :boolean, default: true],
grow: [type: :boolean, default: false],
shrink: [type: :boolean, default: false],
group_level: [type: :pos_integer], # For group_header/footer
columns: [type: :pos_integer, default: 1]
],
entities: [
elements: @element_entities,
grids: [@grid_entity],
tables: [@table_entity],
stacks: [@stack_entity]
]
}Element Entities (13 Types)
Data Elements
# Field - Display data from resource
@field_entity %Spark.Dsl.Entity{
name: :field,
args: [:name],
schema: [
name: [type: :atom, required: true],
source: [type: :atom, required: true], # Field on resource
format: [type: :atom], # :currency, :percent, :date, :datetime, :number
format_spec: [type: :atom], # Reference to format_spec
style: [type: :keyword_list, default: []]
]
}
# Expression - Calculated values
@expression_entity %Spark.Dsl.Entity{
name: :expression,
args: [:name],
schema: [
name: [type: :atom, required: true],
value: [type: {:fun, 1}, required: true], # expr(...)
format: [type: :atom],
style: [type: :keyword_list, default: []]
]
}
# Aggregate - Summary calculations
@aggregate_entity %Spark.Dsl.Entity{
name: :aggregate,
args: [:name],
schema: [
name: [type: :atom, required: true],
type: [type: {:one_of, [:sum, :count, :avg, :min, :max]}, required: true],
field: [type: :atom],
expression: [type: {:fun, 1}],
format: [type: :atom],
style: [type: :keyword_list, default: []]
]
}Text Elements
# Label - Static text
@label_entity %Spark.Dsl.Entity{
name: :label,
args: [:name],
schema: [
name: [type: :atom, required: true],
text: [type: :string, required: true],
style: [type: :keyword_list, default: []]
]
}Visual Elements
# Line - Horizontal/vertical lines
@line_entity %Spark.Dsl.Entity{
name: :line,
args: [:name],
schema: [
name: [type: :atom, required: true],
direction: [type: {:one_of, [:horizontal, :vertical]}, default: :horizontal],
thickness: [type: :pos_integer, default: 1],
color: [type: :string, default: "#000000"],
style: [type: {:one_of, [:solid, :dashed, :dotted]}, default: :solid]
]
}
# Box - Container with borders
@box_entity %Spark.Dsl.Entity{
name: :box,
args: [:name],
schema: [
name: [type: :atom, required: true],
width: [type: :pos_integer],
height: [type: :pos_integer],
border: [type: :keyword_list, default: []],
background: [type: :string],
padding: [type: :keyword_list, default: []]
]
}
# Image - External images
@image_entity %Spark.Dsl.Entity{
name: :image,
args: [:name],
schema: [
name: [type: :atom, required: true],
source: [type: :string, required: true], # Path or URL
width: [type: :pos_integer],
height: [type: :pos_integer],
fit: [type: {:one_of, [:contain, :cover, :fill]}, default: :contain]
]
}Chart Elements
Charts are referenced in bands by name:
# In band elements section
bar_chart :sales_by_region # References chart defined at reports level
line_chart :monthly_trend
pie_chart :market_share
area_chart :cumulative_revenue
scatter_chart :correlation
gantt_chart :project_timeline
sparkline :trend_indicatorChart Entities (Defined at Reports Level)
Charts are defined at the reports section level for reusability:
@bar_chart_entity %Spark.Dsl.Entity{
name: :bar_chart,
args: [:name],
target: AshReports.Charts.Definitions.BarChart,
schema: [
name: [type: :atom, required: true],
driving_resource: [type: :atom, required: true],
base_filter: [type: {:fun, 1}],
load_relationships: [type: {:list, :atom}, default: []]
],
entities: [
transform: [@transform_entity],
config: [@bar_chart_config_entity]
]
}Transform entity for data processing:
@transform_entity %Spark.Dsl.Entity{
name: :transform,
schema: [
group_by: [type: {:or, [:atom, :tuple]}],
as_category: [type: :atom],
as_value: [type: :atom],
as_x: [type: :atom],
as_y: [type: :atom],
sort_by: [type: {:or, [:atom, :tuple]}],
limit: [type: :pos_integer]
],
entities: [
aggregates: [@aggregate_spec_entity]
]
}Variable Entity
Accumulator variables for calculations:
@variable_entity %Spark.Dsl.Entity{
name: :variable,
args: [:name],
target: AshReports.Variable,
schema: [
name: [type: :atom, required: true],
type: [type: {:one_of, [:sum, :count, :average, :min, :max, :custom]}, required: true],
expression: [type: {:fun, 1}], # expr(field_name) or expr(a + b)
initial_value: [type: :any, default: 0],
reset_on: [type: {:one_of, [:detail, :group, :page, :report]}, default: :report]
]
}Example:
variables do
variable :running_total do
type :sum
expression expr(amount)
reset_on :group
end
variable :record_count do
type :count
reset_on :report
end
variable :average_price do
type :average
expression expr(unit_price)
end
endGroup Entity
Data grouping definitions:
@group_entity %Spark.Dsl.Entity{
name: :group,
args: [:name],
target: AshReports.Group,
schema: [
name: [type: :atom, required: true],
expression: [type: {:fun, 1}, required: true],
sort_direction: [type: {:one_of, [:asc, :desc]}, default: :asc],
level: [type: :pos_integer, default: 1]
]
}Format Specification Entity
Custom formatting rules:
@format_spec_entity %Spark.Dsl.Entity{
name: :format_spec,
args: [:name],
target: AshReports.FormatSpecification,
schema: [
name: [type: :atom, required: true],
pattern: [type: :string],
currency: [type: :atom],
locale: [type: :string],
conditions: [type: {:list, :map}, default: []]
]
}Extension Architecture
graph TB
subgraph "Compile Time Processing"
DSL[DSL Code<br/>reports do...end]
Parse[Spark Parser<br/>Macro Expansion]
Entities[Entity Structs<br/>Report, Band, Element]
Trans[Transformers<br/>BuildReportModules]
Verify[Verifiers<br/>Validation]
Gen[Generated Module<br/>definition/0, run/2]
end
DSL --> Parse
Parse --> Entities
Entities --> Trans
Trans --> Gen
Entities --> Verify
Verify -->|Errors| CompileError[Compile Error]
Verify -->|OK| GenProcessing Order
- Spark Parser - Expands macros, builds entity structs
- Transformers - Run in order, can modify DSL state
- Verifiers - Run after transformers, validate final state
- Module Generation - Generated code compiled into BEAM
Transformers
Transformers process the DSL at compile time and can generate code.
BuildReportModules
Location: lib/ash_reports/transformers/build_report_modules.ex
This transformer generates a module for each report with:
defmodule MyApp.Reporting.Reports.SalesReport do
@moduledoc "Generated module for sales_report"
def definition do
%AshReports.Report{
name: :sales_report,
title: "Sales Report",
# ... all report metadata
}
end
def run(params, opts \\ []) do
# Execute report with parameters
AshReports.Runner.run(__MODULE__, params, opts)
end
def render(data, format, opts \\ []) do
# Render data to specified format
AshReports.Runner.render(__MODULE__, data, format, opts)
end
def validate_params(params) do
# Validate parameters against definitions
AshReports.ParameterValidator.validate(definition(), params)
end
def build_query(params) do
# Build Ash query for this report
AshReports.QueryBuilder.build(definition(), params)
end
endWriting Custom Transformers
defmodule MyApp.Transformers.CustomTransformer do
use Spark.Dsl.Transformer
def transform(dsl_state) do
reports = Spark.Dsl.Transformer.get_entities(dsl_state, [:reports])
# Process reports...
{:ok, dsl_state}
end
# Run after BuildReportModules
def after?(AshReports.Transformers.BuildReportModules), do: true
def after?(_), do: false
endVerifiers
Verifiers validate the DSL at compile time and emit errors/warnings.
ValidateReports
Location: lib/ash_reports/verifiers/validate_reports.ex
Validates:
- Unique report names across domain
- Required fields present (name, driving_resource)
- Driving resource is a valid atom
- At least one detail band per report
defmodule AshReports.Verifiers.ValidateReports do
use Spark.Dsl.Verifier
def verify(dsl_state) do
reports = Spark.Dsl.Transformer.get_entities(dsl_state, [:reports])
with :ok <- validate_unique_names(reports),
:ok <- validate_required_fields(reports),
:ok <- validate_driving_resources(reports),
:ok <- validate_detail_bands(reports) do
:ok
end
end
endValidateBands
Location: lib/ash_reports/verifiers/validate_bands.ex
Validates:
- Unique band names within each report
- Valid band types
- Group bands have positive group_level
- Detail band numbers are sequential
- Title band is first, summary band is last
ValidateElements
Location: lib/ash_reports/verifiers/validate_elements.ex
Validates:
- Unique element names within bands
- Required element attributes present
- Chart references exist at reports level
- Expression syntax is valid
Info Module and Introspection
AshReports.Info provides runtime introspection via Spark.InfoGenerator:
defmodule AshReports.Info do
use Spark.InfoGenerator,
extension: AshReports,
sections: [:reports]
# Generated functions:
# - reports(domain) -> [Report.t()]
# - report(domain, name) -> {:ok, Report.t()} | :error
# - charts(domain) -> [Chart.t()]
# - chart(domain, name) -> {:ok, Chart.t()} | :error
endUsage
# Get all reports
reports = AshReports.Info.reports(MyApp.Reporting)
# Get specific report
{:ok, report} = AshReports.Info.report(MyApp.Reporting, :sales_report)
# Get all charts
charts = AshReports.Info.charts(MyApp.Reporting)
# Access report metadata
report.title # "Sales Report"
report.driving_resource # MyApp.Sales.Order
report.bands # [%Band{}, ...]
report.parameters # [%Parameter{}, ...]
report.variables # [%Variable{}, ...]Additional Info Functions
# Get all band names
AshReports.Info.all_band_names(domain)
# Get all variable names
AshReports.Info.all_variable_names(domain)
# Get all parameter names
AshReports.Info.all_parameter_names(domain)
# Get driving resources
AshReports.Info.driving_resources(domain)Complete DSL Example
defmodule MyApp.Reporting do
use Ash.Domain,
extensions: [AshReports.Domain]
reports do
# Chart definition (reusable)
bar_chart :sales_by_region do
driving_resource MyApp.Sales.Order
transform do
group_by :region
as_category :group_key
as_value :total
aggregates do
aggregate type: :sum, field: :amount, as: :total
end
end
config do
width 800
height 400
title "Sales by Region"
colours ["4285F4", "EA4335", "FBBC04"]
end
end
# Report definition
report :quarterly_sales do
title "Quarterly Sales Report"
description "Sales analysis by region and product"
driving_resource MyApp.Sales.Order
formats [:html, :pdf, :json]
page_size :a4
orientation :landscape
parameters do
parameter :quarter, :integer do
required true
constraints min: 1, max: 4
end
parameter :year, :integer do
required true
end
parameter :region, :string do
default "all"
end
end
groups do
group :by_region do
expression expr(region)
sort_direction :asc
end
end
variables do
variable :total_sales do
type :sum
expression expr(amount)
reset_on :report
end
variable :group_total do
type :sum
expression expr(amount)
reset_on :group
end
variable :record_count do
type :count
end
end
bands do
band :title do
type :title
height 60
elements do
label :report_title do
text "Quarterly Sales Report"
style font_size: 24, font_weight: :bold
end
end
end
band :column_headers do
type :column_header
height 30
elements do
label :col_region do
text "Region"
style font_weight: :bold
end
label :col_amount do
text "Amount"
style font_weight: :bold
end
end
end
band :group_header do
type :group_header
group_level 1
height 25
elements do
field :region_name do
source :region
style font_weight: :bold, background: "#f0f0f0"
end
end
end
band :detail do
type :detail
height 20
elements do
field :product_name do
source :product_name
end
field :sale_amount do
source :amount
format :currency
end
end
end
band :group_footer do
type :group_footer
group_level 1
height 25
elements do
label :group_total_label do
text "Region Total:"
style font_weight: :bold
end
aggregate :group_sum do
type :sum
field :amount
format :currency
end
end
end
band :chart_band do
type :detail
elements do
bar_chart :sales_by_region
end
end
band :summary do
type :summary
height 40
elements do
label :grand_total_label do
text "Grand Total:"
style font_size: 16, font_weight: :bold
end
aggregate :grand_total do
type :sum
field :amount
format :currency
style font_size: 16, font_weight: :bold
end
end
end
end
end
end
endNext Steps
- Data Loading Pipeline - How data is fetched and processed
- Rendering System - The rendering pipeline
- Chart System - Chart architecture