Learning the OAA Runner Framework: Part 2 — Transforming Fields

Calvin dressed as a wizard with a star-covered robe and pointy hat, waving a wand over a bubbling cauldron full of spreadsheet cells. The cells transform into different shapes as they float out. Hobbes watches skeptically from a safe distance.

This is Part 2 of the Learning the OAA Runner Framework series. It builds on Part 1 — Your First CSV Upload.

What you’ll learn: Use oaa-runner’s built-in transforms to clean and reshape CSV data before it reaches Veza: lowercase email addresses, combine first and last name, normalize boolean values, parse date strings, and store non-standard columns as custom properties.

Prerequisites

Setup

Create your working directory:

mkdir -p 02-csv-transform/data
cd 02-csv-transform

Copy the following content into a new file at data/users.csv — uppercase column names and inconsistent formatting are intentional:

USER_ID,EMAIL,FIRST_NAME,LAST_NAME,IS_ACTIVE,HIRE_DATE,DEPARTMENT
1001,Alice@Example.COM,Alice,Anderson,yes,2020-01-15,Engineering
1002,BOB@EXAMPLE.COM,Bob,Baker,1,2019-03-22,Engineering
1003,carol@example.com,Carol,Chen,true,2021-06-01,Marketing

Problems to solve:

  • EMAIL arrives in mixed case — OAA expects lowercase

  • IS_ACTIVE uses different truthy values (yes, 1, true) — OAA expects a boolean

  • HIRE_DATE is a date string — OAA expects a parsed datetime

  • DEPARTMENT doesn’t have a standard OAA field to map to

  • name must combine FIRST_NAME and LAST_NAME — neither column has the full name

Copy the following content into a new file at config.yaml:

version: v2
log_level: INFO

app:
  name: Example App - CSV Transform
  application_type: Custom App

sources:
  users:
    destination: LocalUser
    source_type: csv_push
    csv_filepath: data/users.csv
    csv_provider_id: "!env"
    field_mapping:
      USER_ID:
        destination_field: unique_id

You’ll add one transform at a time in the steps below.


Step 1: Lowercase the email address

Add a lowercase transform to the EMAIL mapping:

      EMAIL:
        destination_field: email
        transforms:
          - name: lowercase

lowercase converts the value to lowercase before storing. Email addresses are case-insensitive but APIs and CSVs often include inconsistent casing.


Step 2: Combine first and last name

The CSV has separate FIRST_NAME and LAST_NAME columns, but OAA’s name field expects a single display name. Use jinja_render to combine them:

      FULL_NAME:
        destination_field: name
        transforms:
          - name: jinja_render
            options:
              template: "{{ row.FIRST_NAME }} {{ row.LAST_NAME }}"

jinja_render renders a Jinja2 template against the raw source row. row.<COLUMN> reads any column from the current record.

Note: The mapping key (FULL_NAME) is a label you choose — it does not need to exist as a column in the CSV. Any string works. oaa-runner uses it only as an internal handle for the mapping entry. The actual data comes from the Jinja template, which reads row.FIRST_NAME and row.LAST_NAME directly from the source row.

Also map the individual name columns as custom properties:

      FIRST_NAME:
        destination_field: given_name
        custom_type: STRING
      LAST_NAME:
        destination_field: family_name
        custom_type: STRING

custom_type: STRING is added here even though given_name and family_name are recognized field names, because this part of the config also introduces DEPARTMENT as a custom property (Step 5). Being explicit with custom_type on all non-standard mappings — including name fields — is clearer than mixing implicit and explicit declarations. Part 1 omitted custom_type on these fields because custom properties hadn’t been introduced yet.


Step 3: Normalize the boolean

IS_ACTIVE arrives as yes, 1, or true. Use validate_boolean:

      IS_ACTIVE:
        destination_field: is_active
        transforms:
          - name: validate_boolean

validate_boolean converts common truthy strings (yes, true, 1, on) to True and common falsy strings (no, false, 0, off) to False. Without it, the string "yes" fails OAA’s boolean type check.


Step 4: Parse the date

HIRE_DATE is an ISO date string. Use date_convert:

      HIRE_DATE:
        destination_field: hire_date
        transforms:
          - name: date_convert

date_convert parses ISO 8601 strings, Unix timestamps, and common date formats into a normalized datetime that Veza can index.


Step 5: Store DEPARTMENT as a custom property

DEPARTMENT doesn’t map to a standard OAA user field. Use custom_type: STRING to store it as a custom property on the user record:

      DEPARTMENT:
        destination_field: department
        custom_type: STRING

custom_type tells oaa-runner to create a custom property instead of mapping to a built-in OAA field. Supported types: STRING, BOOLEAN, STRING_LIST, INTEGER.

Built-in fields by destination type

Fields listed here map directly without custom_type. Anything else needs custom_type.

Destination

Built-in fields

LocalUser

unique_id, name, email, is_active, created_at, last_login_at, password_last_changed_at, deactivated_at

LocalGroup

unique_id, name

LocalRole

unique_id, name

Employee (HRIS)

unique_id, name, employee_number, first_name, last_name, is_active, employment_status, email, username, department, job_title, company, cost_center, start_date, termination_date, managers

User (IdP)

identity, name, email, full_name, is_active, is_guest, department, manager_id

Group (IdP)

identity, name, full_name, is_security_group

See the OAA reference docs for the complete field list per entity type.


Complete config

Your config.yaml should now look like this:

version: v2
log_level: INFO

app:
  name: Example App - CSV Transform
  application_type: Custom App

sources:
  users:
    destination: LocalUser
    source_type: csv_push
    csv_filepath: data/users.csv
    csv_provider_id: "!env"
    field_mapping:
      USER_ID:
        destination_field: unique_id
      EMAIL:
        destination_field: email
        transforms:
          - name: lowercase
      FULL_NAME:
        destination_field: name
        transforms:
          - name: jinja_render
            options:
              template: "{{ row.FIRST_NAME }} {{ row.LAST_NAME }}"
      FIRST_NAME:
        destination_field: given_name
        custom_type: STRING
      LAST_NAME:
        destination_field: family_name
        custom_type: STRING
      IS_ACTIVE:
        destination_field: is_active
        transforms:
          - name: validate_boolean
      HIRE_DATE:
        destination_field: hire_date
        transforms:
          - name: date_convert
      DEPARTMENT:
        destination_field: department
        custom_type: STRING

Run it

uv tool run --from oaa-runner oaa app run --config-file=config.yaml --no-push

If it worked, you’ll see:

  • 3 users loaded with no type errors

  • Email addresses converted to lowercase

  • Boolean values parsed correctly (yes, 1, true all → true)

  • HIRE_DATE parsed as a datetime

  • Final output: status: true, report: []

If you see a boolean conversion error, check that IS_ACTIVE values in your CSV are in the list of supported truthy/falsy strings.


What’s next

These transforms handle individual field values. The next example goes further: using generators to automatically create group and role objects from column values, assigning users to them at load time — no separate groups file or hook code needed.

Next: Part 3 — Modeling a Custom Application

See also