Understanding AshCsvInterchange
AshCsvInterchange is an Ash extension (a Spark DSL) for moving CSV data in and
out of Ash resources. A resource declares named CSV import and export types;
imports dispatch to a resource :create/upsert action and re-run idempotently,
exports stream a read action's records out to CSV. It is for teams that need
operator-facing CSV round-trips without hand-rolling parsing, header validation,
and per-row error reporting.
Core concepts
- CSV type: a named declaration (
csv_import :id/csv_export :id) on a resource. Type ids must be unique across all configured domains. - Registry: the extension discovers types by walking the resources of the
domains listed under
config :my_app, AshCsvInterchange, domains: [...].config :ash_csv_interchange, otp_app: :my_appis also required: it names the app whose config holds that list. Without it, the first call raises. - Idempotent import: imports go through the resource's upsert action, so re-running the same file is safe.
Basic usage
defmodule MyApp.Contact do
use Ash.Resource,
extensions: [AshCsvInterchange]
csv_imports do
csv_import :contacts do
label "Contacts Export"
headers required: ["external_id", "first_name"], optional: []
upsert_action :import_from_csv
end
end
end
AshCsvInterchange.import_csv(:contacts, csv_binary, mode: :commit)
{:ok, csv} = AshCsvInterchange.export_csv(:contacts)Imports default to mode: :dry_run, which validates every row and writes
nothing. Pass mode: :commit to write.
An export's read action must support keyset pagination
(pagination keyset?: true, required?: false), because exports stream in
batches. Any calculation or aggregate used as a column must be loaded by
that read action (prepare build(load: [...])).
If the export's read action takes arguments, pass them with :input (a map,
default %{}). Read actions with required arguments raise their
missing-argument error when :input is omitted:
{:ok, csv} =
AshCsvInterchange.export_csv(:contacts_by_last_name,
input: %{last_name: "Lovelace"},
actor: actor
)# Stream a large import so the file is never fully resident, committing
# rows as the stream is consumed (ideal for progressive UI or Oban chunks).
{:ok, %{outcomes: outcomes}} =
AshCsvInterchange.stream_import(:contacts, {:path, "/tmp/contacts.csv"}, mode: :commit)
Enum.each(outcomes, &handle_outcome/1)Anti-patterns
- Do not reuse the same
:idacross resources in one configured domain set — duplicate ids raise at registry-resolution time. - Avoid loading a whole export into memory with
export_csv/2for large data sets; preferstream_export/2, which streams batches. - For large imports, prefer
stream_import/3overimport_csv/3. The%RunReport{}fromimport_csv/3is bounded: counts are exact, but it keeps only the first:max_outcomesoutcomes (default 100), and no Ashrecordunless you passretain_records?: true. To see every failure — to build an error CSV, say — fold thestream_import/3outcome stream yourself. - Streaming sources must be re-enumerable (a binary,
{:path, path}, aFile.Stream, a list, or aStreamover a re-runnable producer). One-shot sources (a consumed network body) are not supported — write them to a file and pass{:path, path}. :commitmode batches writes throughAsh.bulk_create/4(batch_size:option, default 100). Outcomes are identical to per-row commits — rows a batch can't cleanly persist fall back to the single-row path — so only setbatch_size: 1when you specifically need one write round-trip per row.import_csv/3in:commitmode is not atomic. Rows before a malformed or invalid-UTF-8 row are already committed when it returns{:error, _}. Imports are idempotent upserts, so re-running the fixed file is safe.