Enginatics AI Skills

Blitz Report Development / Upload Development

Add DFF Handling to an Upload

add-dff-to-upload

Add descriptive flexfield (DFF) handling to a Blitz Upload - context and attribute columns with context-dependent lists of values, segment prompts as column headers, Success SQL, and the PL/SQL that merges untouched segments, validates the DFF and converts display values to ids before the API call.

When to use

Use when asked to add, validate or fix DFF / attribute columns of an upload, e.g. "add validation for the attribute5 flexfield segment of my upload".

Use

/plugin marketplace add enginatics/ai-skills
/plugin install blitz-report-ai-skills@enginatics

Then ask Claude for the task, or run:

/blitz-report-ai-skills:add-dff-to-upload

Blitz Reports used

View source on GitHub

Skill contents

SKILL.mdreferences/flexfield_facts.sqlscripts/gen_dff.py

SKILL.md

# Add DFF Handling to a Blitz Upload

Adds one descriptive flexfield to an existing upload the standard Blitz Report way. Users see and enter display values in Excel; the download converts stored values to display values, and the upload procedure validates the whole DFF with the Oracle flexfield engine and converts it back to stored ids. A generator script writes every artefact from the flexfield's facts; the steps below say where each one goes. The `upload-development` rules, section E, are the checklist behind it.

## Inputs

- The upload: its XML (exported from the Blitz Report setup form, Tools > Export) and the PL/SQL package of its upload procedure.
- The flexfield, by name or by the table that carries it, and a short column prefix for the entity (`subinv`, `loc`, `line`).
- A development instance to deploy and test on, as described in `blitz-environment.md`.

## Step 0 - Check the Blitz Report build

Run the last query of `references/flexfield_facts.sql`. It must return `P_PARAMETER_PREFIX`. Otherwise the build lacks the shared merge routine `xxen_fnd_upload.merge_dff` that the generated procedure calls. Stop and ask for an upgrade.

## Step 1 - Collect the flexfield facts

Run queries 1 to 3 of `references/flexfield_facts.sql`:

| Result | Generator argument / use |
|---|---|
| `application_id`, `application_short_name`, `descriptive_flexfield_name` | `--app-id`, `--app-short-name`, `--flexfield`. Several DFFs can sit on one table: take the one the Oracle form uses. |
| `context_column_name` | `--context-column` (not always `ATTRIBUTE_CATEGORY`; `GL_CODE_COMBINATIONS` uses `CONTEXT`) |
| attribute column family and count (Query 3) | `--family` (default `ATTRIBUTE`; HR uses `PEI_INFORMATION`, `ORG_INFORMATION`, ...), `--count` = number of attribute columns of the table, whether used or not. Query 3 returns one row per schema with a table of that name: take the product schema's. |
| `max_length` (Query 3) | over 150: the procedure's record holds 150 bytes per attribute, values above that raise `ORA-06502`; tell the user |
| `context_user_override_flag` `N` with a `default_context_field_name` | the context follows a reference field: see Special cases |

## Step 2 - Generate the artefacts

```
python ${CLAUDE_PLUGIN_ROOT}/skills/add-dff-to-upload/scripts/gen_dff.py \
  --app-id 401 --app-short-name INV --flexfield MTL_SECONDARY_INVENTORIES \
  --prefix subinv --count 15 --alias msi \
  --guid <upload GUID> --rule-name "XX Subinventory DFF Attributes" \
  --first-column <COLUMN_NUMBER of UPLOAD_ROW> --first-sequence <last upload parameter's DISPLAY_SEQUENCE + 1> \
  --template-keys ORGANIZATION_CODE,SUBINVENTORY \
  --package <upload package> --procedure <upload procedure> \
  --key-params p_organization_code,p_subinventory \
  --cursor-from "mtl_parameters mp,mtl_secondary_inventories msi" \
  --cursor-where "mp.organization_code=p_organization_code and mp.organization_id=msi.organization_id and msi.secondary_inventory_name=p_subinventory" \
  --out <folder>
```

- `--first-column`: the DFF columns go before `UPLOAD_ROW`, or before the column you name instead. `--first-sequence`: the procedure's new parameters follow its existing ones.
- `--template-keys`: the upload column names that identify the record, as in the upload XML.
- `--alias` must be the alias of the flexfield's own table (Query 1 `application_table_name`) in the main SQL, not of a view: its rowid resolves dependent value sets.
- `--param-prefix` / `--context-param` name the procedure parameters (default `p_attribute`, `p_attribute_category`); give each DFF its own when an upload has several.
- `--dry-run` writes a test build that reports the values the API would receive instead of calling it. Run the tests with it first.
- Aliases must stay within 30 bytes for databases before 12.2; the script warns.

Output: `main_sql_columns.sql`, `success_sql_columns.sql`, `upload_columns.xml`, `upload_parameters.xml`, `translation_rule.xml`, `template.xml`, `<package>.pks`, `<package>.pkb`.

## Step 3 - Upload definition

1. **Main SQL**: insert `main_sql_columns.sql` after the entity's columns, before `upload_row`.
2. **Upload columns**: insert the `upload_columns.xml` rows and renumber the `COLUMN_NUMBER` of the columns after them, which must follow the select-list order.
   - Context column: `LOV custom` over `fnd_descr_flex_contexts_vl`, returning context names. Keep it when the DFF has only global segments: its list stays empty until a context is defined, as in the shipped uploads.
   - Attribute columns: `LOV custom` over `xxen_util.dff_attribute_lov`, with all siblings passed as `:$column$`, the `value~description` split, not free text, and a `select` comment listing the segment per context in the shipped format `(Context) Attribute1: (Global) <prompt>, (<context>) <prompt>`.
   - Keep the generated LOV text as it is (named arguments, lower case, literals at the start): the framework reads those literals from the first 1,000 characters to give Excel per-context date and number checks.
3. **Upload parameters**: insert the `upload_parameters.xml` rows in the procedure's parameter order and renumber `DISPLAY_SEQUENCE` after them.
4. **Success SQL**: add `success_sql_columns.sql` at the same position as in the main SQL. The tables sit in an inline view `x` that selects `rowid row_id`, because pre-12c databases allow an outer-joined table to reference only one other table:
   ```sql
   from
   <data view> xu,
   (
   select
   mp.organization_code,
   msi.rowid row_id,
   msi.*
   from
   mtl_parameters mp,
   mtl_secondary_inventories msi
   where
   mp.organization_id=msi.organization_id
   ) x
   where
   xu.organization_code=x.organization_code(+) and
   xu.subinventory=x.secondary_inventory_name(+) and
   xu.status_code_=xxen_upload.status_success
   ```
   The data view name is shown on the setup form's Upload Results tab.
5. **Templates**:
   - Keep the DFF columns out of the default template. An upload without one shows every column: add a default template with the existing columns.
   - Insert the `template.xml` row into the upload XML's `<TEMPLATES>`. It holds the key columns and all DFF columns, as the shipped uploads do, Upload Mode Update, and Site sharing.
6. **Translation rule**: import `translation_rule.xml` after the upload, as its own file. A rule whose upload does not exist yet is skipped, and rules import before reports in a combined file. It renames the attribute headers to the segment prompts. Developer access can also maintain it under Tools > Dynamic Column Translations.

Import the XML through the setup form (Tools > Import) or `bin/import_xml`. Editing an upload needs System access.

## Step 4 - Upload procedure

Add the generated parameters (`default null`) to the procedure in spec and body. Then merge the generated declarations and DFF block into it. The block never merges segments itself: it calls the shared routine `xxen_fnd_upload.merge_dff`, which every Blitz Upload uses.

- **Read the stored row** with its `rowid` into `l_stored`. The generated code treats a key without a stored row as a create; in an update-only upload, keep the procedure's own not-found error ahead of the DFF block.
- **Fill `l_dff`** with all uploaded cells, then call `merge_dff`. It returns the ids to store and whether they differ from `l_stored`:
  - nothing modified on an existing record: the stored record, no validation;
  - a changed cell takes the uploaded value, an untouched cell the stored value in display form, a column without an enabled segment its stored value unconverted;
  - after a context change the old context's untouched segments are cleared, as in the Oracle form;
  - a blank flexfield is validated only when it requires a value; hidden segments keep their stored values;
  - "modified" means `parameter_exists` plus `value_modified`, as a csv or xlsx file counts every parameter as modified, also those its template omits. Test the entity's other columns the same way: `xxen_upload.new_value` alone clears a column the csv template omits.
- **On error** (`x_message` set) set `message_` and raise. Report "No change." when neither the DFF nor another column changed.
- **Parameter names:** `p_parameter_prefix` plus the attribute number, and the context parameter (`p_context_parameter`, default prefix plus `_category`), must match the declared parameters, or edits are dropped silently. A procedure that reads the upload view itself instead of parameters passes the lowercase column names and the row's `modified_columns_` as `p_modified_columns`.
- **Options:** `p_column_prefix` for another column family (`ORG_INFORMATION`), `p_validate=>false` to keep stored values unconverted without validation (an upload option bypassing DFF validation).
- **API call:** replace `xx_my_api.create_or_update` with the entity's API and check its return status. A cleared cell is null in the record. How to pass it:

| API family | Null means | Pass |
|---|---|---|
| Table handlers, APIs writing every column | clear | the record as is |
| HR (`hr_*_api`) | clear; omitted = `hr_api.g_varchar2` = keep | the record as is, never `hr_api.g_varchar2` for a cleared cell |
| TCA (`hz_*_v2pub`) | keep | on update `fnd_api.g_miss_char` for a null attribute, on create null |
| Interface tables | per import program | its clear marker (WIP Mass Load `fnd_api.g_miss_*`, item revisions `'!'`) |

Never convert the stored record with `validate_dff_attributes('I','V')`: the conversion is all or nothing, so one invalid stored segment blocks every row.

## Special cases

- **Reference-field context** (override `N`, `default_context_field_name` set): give the context column a `DEFAULT_VALUE` that derives the context name from the reference column, as the FND Lookup Upload does for `FND_COMMON_LOOKUPS`.
- **Context set by the Oracle form itself**, with neither override nor reference field (`MTL_EAM_ASSET_ATTR_VALUES`): in `'V'` mode only global segments are validated, and context segments come back as typed, so `merge_dff` does not apply. Convert each segment with `xxen_util.dff_attribute_value_to_id` and validate in `'I'` mode. That function converts only segments with a list; convert dates and numbers with `fnd_date`/`fnd_number` yourself.
- **Value sets joining `fnd_sessions`** (common in HR): call `hr_integration_utils.add_or_update_session(<effective date>)` at the start of the procedure.
- **Date segments**:
  - DFF dates travel as text in the Blitz Report XLSX Date Format profile, or else the ICX date mask, but validation reads them in the ICX mask. Keep the two equal.
  - Set Blitz Report XLSX Use Long Date Format = Yes when a date can fall outside 1950-2049; otherwise even untouched dates change century.
- **Several DFFs in one upload**: one prefix, parameter set and translation rule per DFF. Generate each with its own `--prefix`, `--param-prefix` and `--context-param`, and call `merge_dff` once per DFF.
- **One DFF on several records of a row** (the contact points of a party, a site and a contact all carry the same flexfield): the translated headings must start with the record they belong to (`Site Email Email Invoice`), otherwise identical prompts give duplicate column headings. Edit the generated rule's translation to put the prefix first and truncate only the prompt, so prefix and prompt fit `xxen_report.max_column_length` (30 bytes before database 12.2).

## Step 5 - Deploy and test

1. Compile the package with `--dry-run` code, import the upload XML, then the rule. Check that the attribute headers show the prompts.
2. Download with the Descriptive Flexfield template, edit the rows, upload, and check the result workbook. Excel accepts only list values and valid dates, so write the error rows into the file outside Excel (cells as shared strings, Modified Columns listing the changed column positions). A csv maps by position: Action, Status, Message, then the template's columns in template order.

| Scenario | Expected |
|---|---|
| Change one segment | Success; the other segments keep their stored values |
| Clear a segment | Success; segment cleared (Excel also empties the row's other filled attribute cells: re-enter those that should stay) |
| No DFF change | "No change." |
| Value not in the list / invalid context | `<Column> is invalid` from the list check, naming the column (`Subinv Attribute5`), not the segment prompt; the procedure is not called |
| Invalid date in a free-text date segment | message from `validate_dff_attributes` |
| Context change without a required segment of the new context | "The following required field does not have a value: ..." |
| Context change with values / context emptied | Success; old context segments cleared |
| Upload with the Default template, and a csv without DFF columns uploaded with it | "No change." |

3. Replace the dry-run block with the API call, then repeat the tests and check the table.

## Troubleshooting

| Symptom | Cause |
|---|---|
| List shows `value~description`, downloaded values rejected | LOV returns `column_value` without the split |
| Headers still generic | rule not enabled or not assigned to the upload, prefix differs from the column names, or no segment uses the column |
| Edits report "No change." | `p_parameter_prefix` or `p_context_parameter` of the `merge_dff` call does not match the declared parameters |
| Untouched segments rejected | the stored value is no longer valid (disabled, expired, newly required): correct it in the same row |
| Stored DFF cleared by an upload | parameters passed instead of the merged record, or a csv/xlsx uploaded without a template |
| Every download fails with `ORA-00923` | a plain-text column comment starts with the word "select" |
| Works on 19c, fails on 11.2/12.1 | identifier over 30 bytes, or an outer join to two tables in the Success SQL (`ORA-01417`) |

## Reference

- `xxen_util.display_flexfield_context(app_id, flexfield, context_code)`: returns the context name.
- `xxen_util.display_flexfield_value(app_id, flexfield, context_code, column_name, rowid, value)`: returns the display value.
- `xxen_util.dff_attribute_lov(p_application_id, p_descriptive_flexfield_name, p_context_name, p_column_name, p_lov_delimter default '~', p_attribute1..30)`: pipelined, one `value~description` row per value. Call it with named notation.
- `xxen_fnd_upload.merge_dff(p_application_short_name, p_flexfield_name, p_parameter_prefix, p_create, p_rowid, p_stored_rec, px_dff_rec, x_message, p_context_parameter, p_column_prefix, p_modified_columns, p_validate) return boolean`: merges, validates and converts one DFF; true when the ids differ from the stored record.
- `xxen_fnd_upload.validate_dff_attributes(p_appl_sn, p_flexfield_name, p_values_or_ids_in, p_values_or_ids_out, p_column_prefix, px_attribute_rec, x_error_message)`: on error the record is unchanged and the message is set.
- `xxen_upload.value_modified(p)` / `xxen_upload.parameter_exists(p)`: parameter names in lower case, as declared.
- Shipped uploads to compare: INV Manufacturer Upload (one DFF), AR Customer Upload (five DFFs, one rule with a `case` on the flexfield name), FND Lookup Upload (reference-field context).

references/flexfield_facts.sql

Show references/flexfield_facts.sql
--Query 1: flexfield definition (replace <TABLE_NAME>)
select
fa.application_id,
fa.application_short_name,
fdfv.descriptive_flexfield_name,
fdfv.title,
fdfv.application_table_name,
fdfv.context_column_name,
fdfv.context_user_override_flag,
fdfv.default_context_field_name
from
fnd_descriptive_flexs_vl fdfv,
fnd_application fa
where
fdfv.application_table_name='<TABLE_NAME>' and
fdfv.descriptive_flexfield_name not like '$SRS$.%' and
fdfv.application_id=fa.application_id;

--Query 2: contexts and segments (replace <APPLICATION_ID> and <FLEXFIELD_NAME>)
select
fdfcv.descriptive_flex_context_code context_code,
fdfcv.descriptive_flex_context_name context_name,
fdfcu.application_column_name,
fdfcu.form_left_prompt,
ffvs.flex_value_set_name,
ffvs.validation_type,
ffvs.format_type,
fdfcu.required_flag,
fdfcu.display_flag
from
fnd_descr_flex_contexts_vl fdfcv,
fnd_descr_flex_col_usage_vl fdfcu,
fnd_flex_value_sets ffvs
where
fdfcv.application_id=<APPLICATION_ID> and
fdfcv.descriptive_flexfield_name='<FLEXFIELD_NAME>' and
fdfcv.enabled_flag='Y' and
fdfcv.application_id=fdfcu.application_id and
fdfcv.descriptive_flexfield_name=fdfcu.descriptive_flexfield_name and
fdfcv.descriptive_flex_context_code=fdfcu.descriptive_flex_context_code and
fdfcu.enabled_flag='Y' and
fdfcu.flex_value_set_id=ffvs.flex_value_set_id(+)
order by
fdfcv.global_flag desc,
fdfcv.descriptive_flex_context_code,
fdfcu.column_seq_num;

--Query 3: attribute columns of the table and their length (replace <TABLE_NAME> and <FAMILY>, e.g. ATTRIBUTE)
select
atc.owner,
count(*) attribute_columns,
max(atc.data_length) max_length
from
all_tab_columns atc
where
atc.table_name='<TABLE_NAME>' and
regexp_like(atc.column_name,'^<FAMILY>\d+$')
group by
atc.owner;

--Is the Blitz Report build recent enough? Must return P_PARAMETER_PREFIX.
select
aa.argument_name
from
all_arguments aa
where
aa.package_name='XXEN_FND_UPLOAD' and
aa.object_name='MERGE_DFF' and
aa.argument_name='P_PARAMETER_PREFIX';

scripts/gen_dff.py

Show scripts/gen_dff.py
#!/usr/bin/env python3
"""Generate the descriptive flexfield (DFF) parts of a Blitz Upload.

Writes, for one DFF, every artefact the add-dff-to-upload skill describes:
  main_sql_columns.sql      select-list lines for the upload's main SQL
  success_sql_columns.sql   select-list lines for the Success SQL
  upload_columns.xml        UPLOAD_COLUMNS_ROW elements: context + attribute columns with LOVs and comments
  upload_parameters.xml     UPLOAD_PARAMETERS_ROW elements mapping the procedure parameters
  translation_rule.xml      importable dynamic column translation rule for the upload
  template.xml              TEMPLATES_ROW element: the key columns and the DFF columns, Upload Mode Update
  <package>.pks/.pkb        a complete example package whose procedure carries the DFF handling
Standard library only. Run with --help for the arguments.
"""
import argparse, pathlib, sys, uuid
from xml.sax.saxutils import escape


def x(s):
    return escape(s, {"'": "&apos;"})


def lines(items, sep):
    items = [i.strip() for i in items if i.strip()]
    return [i + (sep if k < len(items) - 1 else "") for k, i in enumerate(items)]


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--app-id", type=int, required=True, help="application_id of the flexfield (Query 1)")
    ap.add_argument("--app-short-name", required=True, help="application_short_name of the flexfield (Query 1)")
    ap.add_argument("--flexfield", required=True, help="descriptive_flexfield_name (Query 1)")
    ap.add_argument("--prefix", required=True, help="column alias prefix, lower case, e.g. subinv")
    ap.add_argument("--count", type=int, required=True, help="number of attribute columns of the table (Query 3)")
    ap.add_argument("--family", default="ATTRIBUTE", help="attribute column family, e.g. ATTRIBUTE or PEI_INFORMATION")
    ap.add_argument("--context-column", default="ATTRIBUTE_CATEGORY", help="context_column_name (Query 1)")
    ap.add_argument("--alias", required=True, help="alias of the flexfield's own table in the main SQL, e.g. msi")
    ap.add_argument("--success-alias", default="x", help="alias of the inline view in the Success SQL")
    ap.add_argument("--param-prefix", default="p_attribute", help="procedure parameter prefix of the attributes")
    ap.add_argument("--context-param", default="p_attribute_category", help="procedure parameter of the context")
    ap.add_argument("--guid", help="GUID of the upload, for translation_rule.xml")
    ap.add_argument("--rule-name", help="name of the dynamic column translation rule")
    ap.add_argument("--first-column", type=int, default=1,
                    help="COLUMN_NUMBER of the upload column the DFF columns go before (usually UPLOAD_ROW)")
    ap.add_argument("--first-sequence", type=int, default=1,
                    help="DISPLAY_SEQUENCE of the last existing upload parameter + 1")
    ap.add_argument("--template-keys", default="",
                    help="comma separated upload column names identifying the record, e.g. ORGANIZATION_CODE,SUBINVENTORY")
    ap.add_argument("--template-name", default="Descriptive Flexfield", help="name of the generated template")
    ap.add_argument("--package", default="xx_my_upload", help="package name of the example procedure")
    ap.add_argument("--procedure", default="upload_record", help="procedure name")
    ap.add_argument("--key-params", default="", help="comma separated key parameters, e.g. p_organization_code,p_subinventory")
    ap.add_argument("--cursor-from", help="comma separated from list of the stored-record cursor (default: the flexfield table)")
    ap.add_argument("--table", help="the flexfield's own table, used when --cursor-from is not given")
    ap.add_argument("--cursor-where", default="", help="predicates of the stored-record cursor, separated by ' and '")
    ap.add_argument("--dry-run", action="store_true", help="report the values the API would receive in message_ instead of calling it")
    ap.add_argument("--out", required=True, help="output directory")
    a = ap.parse_args()

    fam = a.family.upper()
    famlc = fam.lower()
    ctx = a.context_column.lower()
    n = a.count
    attrs = range(1, n + 1)
    pre = a.prefix.lower()
    ctx_alias = f"{pre}_attribute_category"
    col_alias = lambda i: f"{pre}_{famlc}{i}"
    app, dff = a.app_id, a.flexfield
    out = pathlib.Path(a.out)
    out.mkdir(parents=True, exist_ok=True)
    warnings = [f"alias {s} is {len(s)} bytes, over the 30-byte limit of databases before 12.2"
                for s in [ctx_alias, col_alias(n)] if len(s) > 30]
    warnings += [f"parameter {s} is {len(s)} bytes: the import truncates it to 30 and every upload fails with PLS-00306"
                 for s in [a.context_param, f"{a.param_prefix}{n}"] if len(s) > 30]

    def disp(alias, rowid):
        res = [f"xxen_util.display_flexfield_context({app},'{dff}',{alias}.{ctx}) {ctx_alias},"]
        res += [f"xxen_util.display_flexfield_value({app},'{dff}',{alias}.{ctx},'{fam}{i}',{rowid},{alias}.{famlc}{i}) {col_alias(i)},"
                for i in attrs]
        return "\n".join(res)

    ctx_lov = "\n".join(["select", "fdfcv.descriptive_flex_context_name value,", "fdfcv.description", "from",
        "fnd_descr_flex_contexts_vl fdfcv", "where", f"fdfcv.application_id={app} and",
        f"fdfcv.descriptive_flexfield_name='{dff}' and", "fdfcv.enabled_flag='Y' and", "fdfcv.global_flag='N'",
        "order by", "fdfcv.descriptive_flex_context_name"])

    def attr_lov(i):
        sib = ",\n".join([f"p_attribute{j}=>:$column$.{col_alias(j)}" for j in attrs])
        return "\n".join(["select", "substrb(lov.column_value,1,instrb(lov.column_value,'~',1,1)-1) value,",
            "substrb(lov.column_value,instrb(lov.column_value,'~',1,1)+1,lengthb(lov.column_value)) description",
            "from", "table(xxen_util.dff_attribute_lov(", f"p_application_id=>{app},",
            f"p_descriptive_flexfield_name=>'{dff}',", f"p_context_name=>:$column$.{ctx_alias},",
            f"p_column_name=>'{fam}{i}',", sib, ")) lov", "order by", "value"])

    def comment(i):
        return "\n".join(["select",
            "'(Context) '||initcap(y.application_column_name)||': '||listagg(y.entry,', ') within group (order by y.sort_key) comments",
            "from", "(", "select", "x.application_column_name,", "x.entry,", "x.sort_key,",
            "sum(lengthb(x.entry)+2) over (order by x.sort_key) len", "from", "(", "select", "fdfcu.application_column_name,",
            "decode(fdfc.descriptive_flex_context_code,'Global Data Elements','(Global)','('||fdfc.descriptive_flex_context_code||')')||' '||fdfcu.form_left_prompt entry,",
            "decode(fdfc.descriptive_flex_context_code,'Global Data Elements','1','2'||fdfc.descriptive_flex_context_code) sort_key",
            "from", "fnd_descr_flex_contexts fdfc,", "fnd_descr_flex_col_usage_vl fdfcu", "where",
            f"fdfc.application_id={app} and", f"fdfc.descriptive_flexfield_name='{dff}' and", "fdfc.enabled_flag='Y' and",
            "fdfc.application_id=fdfcu.application_id and", "fdfc.descriptive_flexfield_name=fdfcu.descriptive_flexfield_name and",
            "fdfc.descriptive_flex_context_code=fdfcu.descriptive_flex_context_code and", "fdfcu.enabled_flag='Y' and",
            f"fdfcu.application_column_name='{fam}{i}'", ") x", ") y", "where", "y.len<=3900", "group by", "y.application_column_name"])

    rule_sql = "\n".join(["select", f"'{pre.upper()}_'||z.application_column_name column_name,",
        "case when lengthb(z.prompts)>xxen_report.max_column_length then substrb(z.prompts,1,xxen_report.max_column_length-4)||'(..)' else z.prompts end translation",
        "from", "(", "select", "y.application_column_name,", "listagg(y.form_left_prompt,', ') within group (order by y.sort_key) prompts",
        "from", "(", "select", "x.application_column_name,", "x.form_left_prompt,", "x.sort_key,",
        "sum(lengthb(x.form_left_prompt)+2) over (partition by x.application_column_name order by x.sort_key) len", "from", "(",
        "select", "fdfcu.application_column_name,", "fdfcu.form_left_prompt,",
        "min(decode(fdfc.descriptive_flex_context_code,'Global Data Elements','1','2'||fdfc.descriptive_flex_context_code)) sort_key",
        "from", "fnd_descr_flex_contexts fdfc,", "fnd_descr_flex_col_usage_vl fdfcu", "where", f"fdfc.application_id={app} and",
        f"fdfc.descriptive_flexfield_name='{dff}' and", "fdfc.enabled_flag='Y' and", "fdfc.application_id=fdfcu.application_id and",
        "fdfc.descriptive_flexfield_name=fdfcu.descriptive_flexfield_name and",
        "fdfc.descriptive_flex_context_code=fdfcu.descriptive_flex_context_code and", "fdfcu.enabled_flag='Y' and",
        f"fdfcu.application_column_name like '{fam}%'", "group by", "fdfcu.application_column_name,", "fdfcu.form_left_prompt",
        ") x", ") y", "where", "y.len<=4000", "group by", "y.application_column_name", ") z"])

    def el(tag, val, ind=4):
        return f"{' ' * ind}<{tag}>{val}</{tag}>"

    cols = [(ctx_alias.upper(), {"LOV_QUERY_DSP": x(ctx_lov)})]
    cols += [(col_alias(i).upper(), {"LOV_QUERY_DSP": x(attr_lov(i)), "COMMENTS": x(comment(i))}) for i in attrs]
    ucols = []
    for k, (name, o) in enumerate(cols):
        r = ["   <UPLOAD_COLUMNS_ROW>", el("COLUMN_NUMBER", a.first_column + k), el("COLUMN_NAME", name),
             el("COLUMN_TYPE_DSP", "LOV custom"), el("DATA_TYPE", "varchar2")]
        r += [el(t, o[t]) for t in ("LOV_QUERY_DSP", "COMMENTS") if t in o]
        ucols.append("\n".join(r + ["   </UPLOAD_COLUMNS_ROW>"]))
    ups = [(a.context_param, ctx_alias.upper())] + [(f"{a.param_prefix}{i}", col_alias(i).upper()) for i in attrs]
    uparams = ["\n".join(["   <UPLOAD_PARAMETERS_ROW>", el("SOURCE", "U"), el("DISPLAY_SEQUENCE", a.first_sequence + k),
               el("PARAMETER_NAME", p), el("IN_OUT", "in"), el("PARAMETER_TYPE", "Char"), el("COLUMN_NAME", c),
               el("DEFAULTED", "Y"), "   </UPLOAD_PARAMETERS_ROW>"]) for k, (p, c) in enumerate(ups)]
    rule_xml = None
    if a.guid:
        rule_xml = "\n".join(["<ROOT>", "<DYNAMIC_COLUMN_TRANSLATION_RULES>", " <DYNAMIC_COLUMN_TRANSLATION_RULES_ROW>",
            f"  <RULE_NAME>{x(a.rule_name or (pre + ' DFF Attributes'))}</RULE_NAME>", f"  <RULE_SQL>{x(rule_sql)}</RULE_SQL>",
            "  <ENABLED>Y</ENABLED>", "  <REPORTS>", "   <REPORTS_ROW>", f"    <GUID>{a.guid}</GUID>", "   </REPORTS_ROW>",
            "  </REPORTS>", " </DYNAMIC_COLUMN_TRANSLATION_RULES_ROW>", "</DYNAMIC_COLUMN_TRANSLATION_RULES>", "</ROOT>", ""])
    tkeys = [k.strip().upper() for k in a.template_keys.split(",") if k.strip()]
    template_xml = None
    if tkeys:
        tcols = []
        for k, name in enumerate(tkeys + [c for c, _ in cols]):
            r = ["     <TEMPLATE_COLUMNS_ROW>", el("DISPLAY_SEQUENCE", k + 1, 6), el("COLUMN_NAME", name, 6)]
            if k == len(tkeys) - 1:
                r.append(el("FREEZE_PANES", "Y", 6))
            tcols.append("\n".join(r + ["     </TEMPLATE_COLUMNS_ROW>"]))
        template_xml = "\n".join(["   <TEMPLATES_ROW>", el("GUID", uuid.uuid4().hex.upper()), el("TEMPLATE_NAME", x(a.template_name)),
            el("DESCRIPTION", "Key and descriptive flexfield columns."), "    <TEMPLATE_COLUMNS>"] + tcols + ["    </TEMPLATE_COLUMNS>",
            "    <TEMPLATE_PARAMETER_DEFAULTS>", "     <TEMPLATE_PARAMETER_DEFAULTS_ROW>", el("PARAMETER_NAME", "Upload Mode", 6),
            el("DEFAULT_VALUE", "U", 6), "     </TEMPLATE_PARAMETER_DEFAULTS_ROW>", "    </TEMPLATE_PARAMETER_DEFAULTS>",
            "    <TEMPLATE_SHARING>", "     <TEMPLATE_SHARING_ROW>", el("SHARING_LEVEL", "S", 6), el("LEVEL_VALUE", "Site", 6),
            "     </TEMPLATE_SHARING_ROW>", "    </TEMPLATE_SHARING>", "   </TEMPLATES_ROW>", ""])

    # example package
    keys = [k.strip() for k in a.key_params.split(",") if k.strip()]
    params = ["status_ out varchar2,", "message_ out varchar2,"] + [f"{k} in varchar2," for k in keys]
    params += [f"{a.context_param} in varchar2 default null,"]
    params += [f"{a.param_prefix}{i} in varchar2 default null{',' if i < n else ''}" for i in attrs]
    header = f"procedure {a.procedure}(\n" + "\n".join(params) + "\n)"
    spec = f"create or replace package {a.package} as\n\n{header};\n\nend {a.package};\n/\n"
    sel = [f"  {a.alias}.{ctx}" + ("" if ctx == "attribute_category" else " attribute_category")]
    sel += [f"  {a.alias}.{famlc}{i}" + ("" if fam == "ATTRIBUTE" else f" attribute{i}") for i in attrs]
    frm = lines(a.cursor_from.split(",") if a.cursor_from else [f"{a.table} {a.alias}"], ",")
    whr = lines(a.cursor_where.split(" and ") if a.cursor_where else [], " and")
    stored = ["    l_stored.attribute_category:=c.attribute_category;"] + [f"    l_stored.attribute{i}:=c.attribute{i};" for i in attrs]
    newv = [f"  l_dff.attribute_category:={a.context_param};"] + [f"  l_dff.attribute{i}:={a.param_prefix}{i};" for i in attrs]
    api = ",\n".join([f"    {k}=>{k}" for k in keys] + ["    p_attribute_category=>l_dff.attribute_category"] +
        [f"    p_{famlc}{i}=>l_dff.attribute{i}" for i in attrs])
    optional = ""
    if a.context_param != f"{a.param_prefix}_category":
        optional += f",\n  p_context_parameter=>'{a.context_param}'"
    if fam != "ATTRIBUTE":
        optional += f",\n  p_column_prefix=>'{fam}'"

    if a.dry_run:
        write = ("    --test build: report the stored values that would be passed to the API instead of writing them\n"
                 "    message_:=case when l_exists then 'Would update: ' else 'Would create: ' end||\n" + " ||\n".join(
                     ["    'Context='||l_dff.attribute_category"] +
                     [f"    case when l_dff.attribute{i} is not null then ', {fam}{i}='||l_dff.attribute{i} end" for i in attrs]) + ";")
    else:
        write = ("    --pass the context code and stored values to the API or table handler that writes the record\n"
                 "    --and check its return status: on failure set message_ and raise le_validation_error,\n"
                 "    --which rolls the row back to its savepoint\n"
                 f"    xx_my_api.create_or_update(\n{api}\n    );")
    body = f"""create or replace package body {a.package} as

g_application_short_name constant fnd_application.application_short_name%type:='{a.app_short_name}';
g_flexfield_name constant fnd_descriptive_flexs.descriptive_flexfield_name%type:='{dff}';


{header} is
l_exists boolean:=false;
l_rowid rowid;
l_stored xxen_fnd_upload.g_attribute_rec_type;
l_dff xxen_fnd_upload.g_attribute_rec_type;
l_dff_message varchar2(4000);
l_changed boolean;
le_validation_error exception;
begin
  status_:=xxen_upload.g_status_success;
  savepoint before_row;
  for c in (
  select
  {a.alias}.rowid row_id,
{(","+chr(10)).join(sel)}
  from
{chr(10).join('  '+l for l in frm)}
  where
{chr(10).join('  '+l for l in whr)}
  ) loop
    l_exists:=true;
    l_rowid:=c.row_id;
{chr(10).join(stored)}
  end loop;
{chr(10).join(newv)}
  l_changed:=xxen_fnd_upload.merge_dff(
  p_application_short_name=>g_application_short_name,
  p_flexfield_name=>g_flexfield_name,
  p_parameter_prefix=>'{a.param_prefix}',
  p_create=>not l_exists,
  p_rowid=>l_rowid,
  p_stored_rec=>l_stored,
  px_dff_rec=>l_dff,
  x_message=>l_dff_message{optional}
  );
  if l_dff_message is not null then
    message_:=l_dff_message;
    raise le_validation_error;
  end if;

  --merge the entity's other columns here and set l_changed when one of them changed; test each with
  --xxen_upload.parameter_exists and xxen_upload.value_modified, since a csv or xlsx file counts every
  --parameter as modified, also one whose column the upload's template leaves out

  if not l_exists or l_changed then
{write}
  else
    message_:='No change.';
  end if;
exception
  when le_validation_error then
    rollback to before_row;
    status_:=xxen_upload.g_status_error;
end {a.procedure};


end {a.package};
/
"""
    crlf = lambda s: s.replace("\r\n", "\n").replace("\n", "\r\n")
    files = {
        "main_sql_columns.sql": disp(a.alias, f"{a.alias}.rowid") + "\n",
        "success_sql_columns.sql": disp(a.success_alias, f"{a.success_alias}.row_id") + "\n",
        "upload_columns.xml": "\n".join(ucols) + "\n",
        "upload_parameters.xml": "\n".join(uparams) + "\n",
        f"{a.package}.pks": spec,
        f"{a.package}.pkb": body,
    }
    if rule_xml:
        files["translation_rule.xml"] = rule_xml
    if template_xml:
        files["template.xml"] = template_xml
    for name, text in files.items():
        (out / name).write_bytes(crlf(text).encode("utf-8"))
    print(f"written to {out}: " + ", ".join(sorted(files)))
    for w in warnings:
        print("WARNING: " + w)
    if not a.guid:
        print("NOTE: no --guid given, translation_rule.xml not written")
    if not tkeys:
        print("NOTE: no --template-keys given, template.xml not written")


if __name__ == "__main__":
    main()