This guide takes one resource from nothing to a working CSV import and
export. By the end you will have a Contact resource that accepts a
contacts spreadsheet, reports what each row would do, commits it, and
writes the same data back out.
Install
def deps do
[
{:ash_csv_interchange, "~> 0.1.0"}
]
endRun mix deps.get, then add the package to import_deps in
.formatter.exs so mix format writes the DSL without parentheses:
[
import_deps: [:ash, :ash_csv_interchange],
# ...
]Configure
The library needs two pieces of config: the OTP app that owns your CSV resources, and the domains to search for CSV types under that app.
# config/config.exs
config :ash_csv_interchange, otp_app: :my_app
config :my_app, AshCsvInterchange, domains: [MyApp.Crm]Both lines are required. A library can't tell which app it runs inside, so
without otp_app the first call raises and explains what to set.
Define a resource
Add the extension, then declare an import and an export. This example uses AshPostgres, but any data layer that supports upserts works.
defmodule MyApp.Crm.Contact do
use Ash.Resource,
domain: MyApp.Crm,
data_layer: AshPostgres.DataLayer,
extensions: [AshCsvInterchange]
postgres do
table "contacts"
repo MyApp.Repo
end
csv_imports do
csv_import :contacts do
label "Contacts"
headers required: ["external_id", "first_name"],
optional: ["last_name", "email"]
upsert_action :import_from_csv
end
end
csv_exports do
csv_export :contacts do
label "Contacts"
read_action :for_csv_export
columns [
{"external_id", :external_id},
{"first_name", :first_name},
{"last_name", :last_name},
{"email", :email}
]
end
end
actions do
defaults [:read]
create :import_from_csv do
accept [:external_id, :first_name, :last_name, :email]
upsert? true
upsert_identity :unique_external_id
end
read :for_csv_export do
pagination keyset?: true, required?: false
prepare build(sort: [external_id: :asc])
end
end
attributes do
uuid_primary_key :id
attribute :external_id, :string, allow_nil?: false, public?: true
attribute :first_name, :string, allow_nil?: false, public?: true
attribute :last_name, :string, public?: true
attribute :email, :string, public?: true
timestamps()
end
identities do
identity :unique_external_id, [:external_id]
end
endThe pieces that matter:
- The identity says what makes two rows the same contact. Here it is
external_id, the ID from the system the spreadsheet came from. - The upsert action must be a
:createaction withupsert? trueand anupsert_identity. Importing the same file twice then updates the existing contacts instead of duplicating them. - Every header maps to an input.
"first_name"becomes the:first_nameinput, so the action must accept that attribute or declare that argument. The extension checks this at compile time. timestamps()lets import reports tell created rows from updated ones. Without it they only report success or failure.- The export's read action needs keyset pagination, because exports
stream records in batches.
required?: falsekeeps it usable unpaginated elsewhere.
Check a file with a dry run
Imports run in :dry_run mode unless you say otherwise. A dry run builds
and validates every row's changeset but writes nothing:
csv = """
external_id,first_name,last_name,email
C-1,Ada,Lovelace,ada@example.com
C-2,Grace,,grace@example.com
C-3,,Lamarr,hedy@example.com
"""
{:ok, report} = AshCsvInterchange.import_csv(:contacts, csv)
report.counts
#=> %AshCsvInterchange.Import.RunReport.Counts{
#=> total: 3, succeeded: 2, failed: 1, created: 0, updated: 0,
#=> blank_rows_skipped: 0
#=> }
Enum.reject(report.outcomes, &(&1.status == :ok))
#=> [%AshCsvInterchange.Import.RowOutcome{line_no: 4, status: :invalid, ...}]Row 4 (line 1 is the header) has no first_name, which the resource
requires. The empty last_name on row 3 is fine: empty cells reach the
action as "", which Ash casts to nil.
Commit
Fix the file, or accept that bad rows will be skipped, and commit:
{:ok, report} = AshCsvInterchange.import_csv(:contacts, csv, mode: :commit)
report.counts
#=> %{total: 3, succeeded: 2, failed: 1, created: 2, updated: 0, ...}A failing row never stops the run. Commit the same file again and the two
good rows come back as updated: 2.
Export
The export reads through :for_csv_export and writes one column per
declared tuple, in order:
{:ok, csv} = AshCsvInterchange.export_csv(:contacts)
#=> {:ok, "external_id,first_name,last_name,email\r\nC-1,Ada,Lovelace,ada@example.com\r\n..."}The import and export share the :contacts id and the same headers, so an
exported file can be edited and imported straight back.
Next steps
- Defining imports: header mapping, reshaping values in the action, stamping imported records, and the compile-time checks.
- Running imports: reading reports, handling errors, streaming large files, and batching.
- Exports: columns, formatters, calculations, read-action arguments, and streaming.
- The DSL reference lists every option.