Packages

An Ash extension for declaring CSV-importable and CSV-exportable resources via a Spark DSL.

Current section

Files

Jump to
ash_csv_interchange guides getting-started.md
Raw

guides/getting-started.md

# Getting started

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

```elixir
def deps do
  [
    {:ash_csv_interchange, "~> 0.1.0"}
  ]
end
```

Run `mix deps.get`, then add the package to `import_deps` in
`.formatter.exs` so `mix format` writes the DSL without parentheses:

```elixir
[
  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.

```elixir
# 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.

```elixir
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
end
```

The 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 `:create` action with `upsert? true` and
  an `upsert_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_name` input, 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?: false` keeps 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:

```elixir
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:

```elixir
{: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:

```elixir
{: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](defining-imports.md): header mapping, reshaping
  values in the action, stamping imported records, and the compile-time
  checks.
- [Running imports](running-imports.md): reading reports, handling errors,
  streaming large files, and batching.
- [Exports](exports.md): columns, formatters, calculations, read-action
  arguments, and streaming.
- The [DSL reference](DSL-AshCsvInterchange.md) lists every option.