RefData

Elixir CI

Most applications have short lists that rarely change: countries, titles, payment terms, subscription plans. RefData keeps those lists in JSON files in your project instead of database tables, and gives you three things for each one:

It has no runtime dependencies.

Installation

Add RefData to your dependencies. It requires Elixir 1.18 or later.

def deps do
  [{:ref_data, "~> 0.3.0"}]
end

Quick start with Phoenix

This walks one field, a customer's country, through a form: rendering the choices, validating the submission, and displaying the stored value.

1. Create a dataset. Run mix ref_data.init to create priv/ref_data/, then add priv/ref_data/countries.json. Each option has a label to show (key) and a value to store (value):

{
  "name": "countries",
  "data": [
    {"key": "Australia", "value": "AU"},
    {"key": "Canada", "value": "CA"},
    {"key": "New Zealand", "value": "NZ"}
  ]
}

2. Tell RefData where it is, in config/runtime.exs:

config :ref_data, path: Application.app_dir(:my_app, "priv/ref_data")

3. Render the choices. RefData.get/2 returns options in the shape Phoenix select inputs accept:

<.input field={@form[:country]} type="select" options={RefData.get("countries")} prompt="Choose a country" />

4. Validate the submission. Never trust that a submitted value came from your list. In an Ecto changeset:

def changeset(customer, attrs) do
  customer
  |> cast(attrs, [:country])
  |> validate_change(:country, fn :country, value ->
    if RefData.valid_value?("countries", value), do: [], else: [country: "is not a valid choice"]
  end)
end

5. Display the stored value. The database holds "CA"; show the label:

<p>Country: {RefData.label("countries", @customer.country)}</p>

That is the whole workflow. The rest of this document covers each part in detail.

Writing datasets

Each .json file in the directory defines one dataset. RefData loads them when your application starts; restart the application to pick up changed files.

Two words are used throughout:

The examples in this document use the five files below.

A plain list, when the label and the stored value are the same. titles.json:

{"titles": ["Dr", "Mr", "Mrs", "Ms", "Mx"]}

Labels with separate values, when you store a code. plans.json:

{
  "plans": [
    {"key": "Starter", "value": "starter"},
    {"key": "Pro", "value": "pro"},
    {"key": "Legacy", "value": "legacy"}
  ]
}

Numbers and booleans keep their JSON types, so you store 30, not "30". payment_terms.json:

{
  "payment_terms": [
    {"key": "Net 7", "value": 7},
    {"key": "Net 30", "value": 30},
    {"key": "Net 60", "value": 60}
  ]
}

Groups, which render as <optgroup> elements. countries_by_region.json:

{
  "countries_by_region": [
    {"Oceania": [
      {"key": "Australia", "value": "AU"},
      {"key": "New Zealand", "value": "NZ"}
    ]},
    {"Americas": [
      {"key": "Canada", "value": "CA"}
    ]}
  ]
}

The fifth file is countries.json from the quick start. It uses the alternative {"name": ..., "data": [...]} layout, which means the same as the single-key form.

The rules:

Everyday use

Every iex> example below, and in the function documentation, runs as part of the test suite against the five files above.

Select options

get/2 returns a dataset ready for a select input. A plain list gives each option the same label and value.

Stored in titles.json:

{"titles": ["Dr", "Mr", "Mrs", "Ms", "Mx"]}
iex> RefData.get("titles")
[
  [key: "Dr", value: "Dr"],
  [key: "Mr", value: "Mr"],
  [key: "Mrs", value: "Mrs"],
  [key: "Ms", value: "Ms"],
  [key: "Mx", value: "Mx"]
]

Groups come back as {label, options} tuples.

Stored in countries_by_region.json:

{"countries_by_region": [
  {"Oceania": [{"key": "Australia", "value": "AU"}, {"key": "New Zealand", "value": "NZ"}]},
  {"Americas": [{"key": "Canada", "value": "CA"}]}
]}
iex> RefData.get("countries_by_region")
[
  {"Oceania", [[key: "Australia", value: "AU"], [key: "New Zealand", value: "NZ"]]},
  {"Americas", [[key: "Canada", value: "CA"]]}
]

Both shapes can be given to a Phoenix <.input type="select"> or to Phoenix.HTML.Form.options_for_select/2.

A dataset that does not exist is handled three ways, depending on what suits the caller:

iex> RefData.get("missing")
nil

iex> RefData.fetch("missing")
:error

iex> RefData.list_all_keys()
["countries", "countries_by_region", "payment_terms", "plans", "titles"]

RefData.get!("missing") raises KeyError. Use it in templates, where a misspelt dataset name is a bug you want to hear about.

Retiring an option

Suppose the Legacy plan is no longer sold, but existing customers are still on it.

Stored in plans.json:

{"plans": [
  {"key": "Starter", "value": "starter"},
  {"key": "Pro", "value": "pro"},
  {"key": "Legacy", "value": "legacy"}
]}

Pass the plan's label in :disabled to grey it out in the form:

iex> RefData.get("plans", disabled: ["Legacy"])
[
  [key: "Starter", value: "starter"],
  [key: "Pro", value: "pro"],
  [key: "Legacy", value: "legacy", disabled: true]
]

A browser will not submit a disabled option, but a crafted request can. Pass the same list when validating so the stored value "legacy" is rejected, while the other plans stay valid:

iex> RefData.validate_value("plans", "legacy", disabled: ["Legacy"])
{:error, :disabled_value}

iex> RefData.validate_value("plans", "pro", disabled: ["Legacy"])
{:ok, "pro"}

Existing customers still see the right name, because labels ignore :disabled:

iex> RefData.label("plans", "legacy")
"Legacy"

:disabled applies only to the call it is passed to. It does not change the stored dataset, so you can decide per form or per user what to disable.

Validating submissions

validate_value/3 checks a submitted value against a dataset's values and returns the value to store.

Stored in payment_terms.json, where the values are numbers:

{"payment_terms": [
  {"key": "Net 7", "value": 7},
  {"key": "Net 30", "value": 30},
  {"key": "Net 60", "value": 60}
]}

Browsers submit everything as strings, so a string is matched against each value's text form and the stored number comes back:

iex> RefData.validate_value("payment_terms", "30")
{:ok, 30}

45 is not one of the stored values, and "Net 30" is a label rather than a value:

iex> RefData.validate_value("payment_terms", "45")
{:error, :invalid_value}

iex> RefData.validate_value("payment_terms", "Net 30")
{:error, :invalid_value}

Values inside groups are found. countries_by_region.json, shown above, stores "NZ" under Oceania:

iex> RefData.validate_value("countries_by_region", "NZ")
{:ok, "NZ"}

iex> RefData.validate_value("countries_by_region", "Oceania")
{:error, :invalid_value}

In a controller or LiveView, use the returned value rather than the raw parameter:

case RefData.validate_value("payment_terms", params["payment_terms"]) do
  {:ok, days} -> Invoices.create(customer, due_in_days: days)
  {:error, _reason} -> {:error, "Choose a payment term from the list"}
end

When you only need yes or no, as in the changeset in the quick start, use valid_value?/3. For a multi-select field, check each submitted value. The countries dataset stores "AU", "CA" and "NZ", so only "XX" is rejected:

iex> submitted = ["AU", "NZ", "XX"]
iex> Enum.reject(submitted, &RefData.valid_value?("countries", &1))
["XX"]

The possible errors are:

Error Meaning
:unknown_dataset No dataset has that name.
:invalid_value No option has that value.
:disabled_value Every option with that value is in :disabled.
:ambiguous_value A string matches two different values, such as 30 and "30" in one dataset. Avoid mixing them.

Matching is exact. "30" matches the number 30, but "030", "30.0" and " 30" do not. A number or boolean passed from Elixir must match the stored type, and nil, lists and maps are never valid. If two labels share a value, the value is valid while either label is enabled.

Displaying stored values

label/3 turns a stored value back into its label.

Stored in payment_terms.json:

{"payment_terms": [
  {"key": "Net 7", "value": 7},
  {"key": "Net 30", "value": 30},
  {"key": "Net 60", "value": 60}
]}

A customer record holds the number 30, or the string "30" if it came straight from a form. Both find the label, and 45 is not stored so it has none:

iex> RefData.label("payment_terms", 30)
"Net 30"

iex> RefData.label("payment_terms", "30")
"Net 30"

iex> RefData.label("payment_terms", 45)
nil

Groups are searched too. countries_by_region.json stores "CA" with the label "Canada" under Americas:

iex> RefData.label("countries_by_region", "CA")
"Canada"

It returns nil for an unknown dataset or value, so a template can supply a fallback: RefData.label("plans", @customer.plan) || "Unknown plan". If several labels share a value, the first one is returned.

A module for your application

use RefData defines the same functions on a module of your own. It gives the rest of your code one place to call, and one place to change if you later move the data to a server you supervise yourself:

defmodule MyApp.RefData do
  use RefData
end

MyApp.RefData.get("titles")
MyApp.RefData.valid_value?("countries", "CA")
MyApp.RefData.label("countries", "CA")

Advanced

Most applications need nothing in this section.

File locations and releases

Keep datasets under your application's priv/ directory. Mix bundles priv/ into releases, and Application.app_dir/2 finds it wherever the release is installed, which is why the path is set in config/runtime.exs. A relative path such as "priv/ref_data" works in development but depends on the directory the release is started from.

An empty directory is valid. A missing directory or an invalid file stops your application from starting, with an error that names the path. If no :path is configured, RefData starts nothing, and lookups exit with :noproc until you configure one or name a source yourself.

Treat datasets as trusted input. Files are read whole, with no size limits, so point RefData only at directories your application controls and never at uploaded files.

Separate sets of data

To keep independent sets of datasets, for example one per context, start an instance for each in your supervision tree and leave :path unconfigured:

children = [
  {RefData, name: MyApp.Billing.RefData, path: Application.app_dir(:my_app, "priv/billing")},
  {RefData, name: MyApp.Shipping.RefData, path: Application.app_dir(:my_app, "priv/shipping")}
]

Select one with :server, or bind a module to it so callers do not repeat the name:

RefData.get("payment_terms", server: MyApp.Billing.RefData)

defmodule MyApp.Billing.Options do
  use RefData, server: MyApp.Billing.RefData
end

MyApp.Billing.Options.get("payment_terms")

Pass name: nil to start an unnamed instance and query it by PID.

Scripts, jobs and tests

RefData.load/1 reads a directory into a catalog, a plain value you hold yourself. Nothing needs to be configured or started. This import script validates a CSV of customers before inserting them:

{:ok, catalog} = RefData.load("priv/ref_data")

for line <- File.stream!("customers.csv") |> Enum.drop(1) do
  [name, country] = line |> String.trim() |> String.split(",")

  case RefData.validate_value("countries", country, source: catalog) do
    {:ok, code} -> IO.puts("importing #{name} (#{RefData.label("countries", code, source: catalog)})")
    {:error, reason} -> IO.puts("skipping #{name}: #{reason}")
  end
end

Every lookup, label and validation function accepts source: catalog. A catalog is a snapshot: it does not change if the files do, and loading one does not affect the shared storage. Call RefData.load/1 again for fresh data. It returns {:error, reason} for a missing directory or an invalid file.

In tests, a catalog loaded from a fixture directory lets you exercise a function without starting anything.

How storage works

Shared storage Catalog
Started by Configuring :path, or adding {RefData, ...} to a supervision tree Calling RefData.load/1
Data lives in An ETS table readable by every process on the node A value held by the caller
Selected with Nothing for the default, or server: name source: catalog
Reload changed files Restart the instance Load a new catalog

Migrating from 0.2

Development

Install the versions in .tool-versions (Elixir 1.20.5 and OTP 29.1.2), then run:

mix deps.get
mix format --check-formatted
mix compile --warnings-as-errors
mix test --warnings-as-errors
mix credo --strict
mix hex.audit
mix docs --warnings-as-errors
mix hex.build

CI tests Elixir 1.18, 1.19 and 1.20 across OTP 27 to 29. The documented examples run against the datasets in test/fixtures/docs.