# Slice 4 — extending: adding a custom field per tenant

xly is sold to many customers. Each customer wants to track a few things
the framework doesn't know about — a custom code, an internal note, a
classification tag. The supported way to add such a field, **without
forking the codebase or altering shared metadata**, is to insert a row
into a *customization* table whose contents merge over the base form at
runtime.

This slice traces that mechanism. It's the first chapter where we cover
xly's *per-tenant overlays* — the customization model that makes the
single-codebase / many-customers SaaS work.

## Three levels of customization

xly stacks **three** distinct customization tables on top of every base
form. Each lives at a different scope and answers a different question.

| Table | Scope | Layer overridden |
|---|---|---|
| [`gdsconfigformpersonalize`](../auto-catalog/tables/gdsconfigformpersonalize.md) | per tenant (`sBrandsId` + `sSubsidiaryId`) | **form-master** — overrides `sSqlStr` / `sWhere` / `sOrder` for the whole form |
| [`gdsconfigformcustomslave`](../auto-catalog/tables/gdsconfigformcustomslave.md) | per tenant | **form-slave** — adds, hides, or replaces *individual fields* on the form |
| [`gdsconfigformuserslave`](../auto-catalog/tables/gdsconfigformuserslave.md) | per user | **form-slave** — per-user field tweaks (column order, hidden columns) |

The base form is `gdsconfigformmaster` + `gdsconfigformslave` and is the
*system default* — what every tenant sees unless overridden. The three
tables above are *overlays*, applied (and merged) at request time by
the runtime.

This slice focuses on the middle row — `gdsconfigformcustomslave` — the
canonical "add a custom field for tenant X" channel.

## What the slice would look like (a worked example)

Imagine a tenant 山东星海印务 wants to add a "客户内部编码" (Customer
Internal Code) field to the customer-list form. They (or an
implementer) does this without touching `gdsconfigformslave`. Instead:

1. Open `界面显示内容配置` in BACK (`gdsmodule.sId=11`,
   `/jmnrpz`). Its third panel writes to `gdsconfigformcustomslave`
   via the form-master at `sId=19211681019715596285250620` — verified
   live. See "Open verification items" item 1 below for the
   per-panel mapping.
2. Add a new row with:
   - `sParentId` = the form's `sId` (same form the base slaves point to)
   - `sName = 'sInternalCode'` (the field's column name)
   - `sChinese = '客户内部编码'`
   - `sControlName = '文本框'` (text input)
   - `sBrandsId` + `sSubsidiaryId` = the tenant's IDs
3. Optionally add a column to the underlying physical table (manual
   schema migration — the framework does *not* automatically `ALTER
   TABLE`). Otherwise the field exists only on the form and would have
   nothing to bind to on save.

The next time anyone in that tenant loads the form, they see the extra
column. Every other tenant continues to see the unmodified base form.

> **Confirmed against the dev DB:** `gdsconfigformcustomslave` is
> currently **empty** in `xlyweberp_saas_ai` (0 rows). The table is
> wired into the framework but no tenant on this dev DB has registered
> any field-level override. The trace below is code-derived; the
> end-to-end *observed* behaviour requires a tenant deployment that
> actually populates the table.

## How the runtime merges

The frontend doesn't see three tables — it sees one merged form. The
framework does the merge through two database **views** that join the
base slave and the override slave with the form-master:

- [`gdsconfigformslavemasterview`](../auto-catalog/views/gdsconfigformslavemasterview.md)
  — joins `gdsconfigformmaster` with the *base* `gdsconfigformslave`.
  Always loaded.
- [`gdsconfigformcustomslavemasterview`](../auto-catalog/views/gdsconfigformcustomslavemasterview.md)
  — joins `gdsconfigformmaster` with the *custom* `gdsconfigformcustomslave`.
  Loaded only for tenants that have rows.

The service layer reads both, merges by `sName`, and returns a single
slave list to the SPA. If a tenant's `gdsconfigformcustomslave` row has
the same `sName` as a base slave, it overrides; if it's a new `sName`,
it's appended. Hidden / removed cases are handled via `bVisible`.

The form-level overrides in `gdsconfigformpersonalize` are merged in a
similar way, but at the *form-master* level: at request time, the
runtime calls
`configformpersonalizeService.getConfigformpersonalize(sBrandsId,
sSubsidiaryId, gdsconfigformmasterId)` (you can see the call from the
Slice-1 trace —
`BusinessBaseServiceImpl.java:325-327`) and, if it
returns a row, swaps its `sConfigSqlStr` / `sConfigWhere` /
`sConfigOrder` over the base form's `sSqlStr` / `sWhere` / `sOrder`.

So the merge order, from base to most-specific, is:

```
gdsconfigformmaster              (system default for the form)
  ↓ overlaid by
gdsconfigformpersonalize         (per-tenant whole-form override)
  ↓ then base slaves
gdsconfigformslave               (system default fields)
  ↓ overlaid / extended by
gdsconfigformcustomslave         (per-tenant fields)
  ↓ optionally further tweaked by
gdsconfigformuserslave           (per-user view preferences)
```

This is the "no-FK, semantic-FK reality" applied to the framework's own
configuration: there are no foreign keys enforcing that
`gdsconfigformcustomslave.sParentId` actually exists in
`gdsconfigformmaster.sId`. Orphan rows are possible and would be silently
ignored at merge time. A maintainer audit script that flags such orphans
belongs in the maintainer toolkit; the current runtime does not enforce
that relationship.

## Why it works without code changes — and what that costs

The end-customer never asks an engineer for a new column for the
*display* side. They open the BACK builder, add the row, the field
appears in FROUNT for their tenant only. The system's other tenants
are untouched.

The price for that property:

- **The merge runs on every request** (not just on overlay-row
  changes). Even tenants with zero `gdsconfigformcustomslave` rows
  pay the runtime cost of checking — the framework can't tell upfront
  whether a tenant has overrides, so the merge code path runs always.
- **Three near-empty tables on every schema.** The three customization
  tables exist whether the tenant uses them or not. In this dev DB
  `gdsconfigformcustomslave` has 0 rows; the table is still indexed,
  backed up, and queried.
- **Display extension only.** The overlay can render an extra field;
  it cannot store its value unless the underlying physical table
  already has the column. So "no code change for a new field" is true
  only for *display-only* fields. Real new persisted fields still
  need a coordinated `ALTER TABLE` (Slice 5 territory) — which means
  the wins from "no code change" don't apply to the cases that
  actually move business value.
- **Debuggability gets worse.** "Why does tenant A see this field
  but tenant B doesn't?" requires diffing
  `gdsconfigformcustomslave` + `gdsconfigformpersonalize` +
  `gdsconfigformuserslave` rows for both tenants. The merge logic in
  `BusinessBaseServiceImpl` is non-trivial; reproducing the exact
  layout a user sees often means re-running the merge by hand.

## Concepts this slice introduces

- *Customization layers* (new concept page) — the three-overlay model
  above; how the merge happens; when each layer applies.
- *Schemaless extension limits* — the framework will render extra
  fields, but it does not (and cannot) `ALTER TABLE`. Custom fields
  beyond what the base table provides require either (a) coordinated
  manual schema migration on a "wide" base table, or (b) a JSON column
  pattern. This slice's example assumes (a). Slice 5 (per-customer SQL
  overrides) covers (b) for the cases where structural change is
  needed.

## Reference entries this slice exercises

- [Builder: how to define a form](../reference/builder/define-form.md) —
  add a section on the override path: same recipe, different table.
- [Maintainer: the runtime](../reference/maintainer/runtime.md) — the
  merge-three-layers procedure happens inside `BusinessBaseServiceImpl`
  and `BusinessGdsconfigformsServiceImpl`. Worth a dedicated paragraph.

## Open verification items

1. ~~**Find the live BACK page that edits `gdsconfigformcustomslave`.**~~
   **CLOSED — confirmed `界面显示内容配置`** (`gdsmodule.sId=11`, URL
   `/jmnrpz`). The page renders three form-master panels in one screen,
   one for each layer of the form-definition stack:

   | Panel | `gdsconfigformmaster.sId` | `sTbName` it writes |
   |---|---|---|
   | Form-master editor | `19211681019715574673782610` | `gdsconfigformmaster` |
   | Base slave editor | `19211681019715596207594120` | `gdsconfigformslave` |
   | Per-tenant overlay | `19211681019715596285250620` | `gdsconfigformcustomslave` |

   The third panel is the canonical channel for "add a custom field for
   tenant X". Verified live: clicking 界面显示内容配置 in BACK
   (admin/123) fires `POST /xlyEntry/business/getBusinessDataByFormcustomId/19211681019715596285250620?sModelsId=11`
   to load the existing customslave rows; subsequent 新增/修改 operations
   route their `addUpdateDelBusinessData` POST to that same form-master,
   which the runtime resolves to a write against
   `gdsconfigformcustomslave` (per the standard universal save path).
2. ~~**Trace the merge code.**~~ **CLOSED** — the merge happens in
   Java at `BusinessBaseServiceImpl.java:246-248`: it calls
   `businessGdsconfigformsService.getFormSlaveData(map)` then
   `getFormCustomSlaveData(map)` and `addAll`s them into one
   `slaveList`. The two views (`gdsconfigformslavemasterview`,
   `gdsconfigformcustomslavemasterview`) supply the joined-with-master
   shape that each call reads — the *merge* is Java; the
   *master-with-slave join* is SQL.
3. ~~**`bVisible = false` semantics — hide-base or suppress-override?**~~
   **CLOSED — both, at different layers.** In
   `BusinessGdsconfigformsServiceImpl.java:413-433`, when a
   `gdsconfigformcustomslave` row matches a base `gdsconfigformslave`
   row by `sControlName` *or* `sName`, the customslave row replaces
   the base row entirely (`sList.removeAll(_cstlist); sList.addAll(_cList);`),
   so `bVisible=false` on the customslave row hides the base field
   for that tenant. The user-level overlay
   (`gdsconfigformuserslave`, lines 446-468) then runs on top: when
   the user-row's `bVisible` is true *and* the merged row's `bVisible`
   is true, the user's `iFitWidth`/`iOrder` apply; otherwise line 464
   explicitly sets `cmap.put("bVisible", false)` on the merged row —
   hiding it from that user only. So `bVisible=false` does hide the
   field at either layer; the scope (per-tenant vs per-user) differs.
> **Item 4 — Deferred (needs populated tenant deployment).**
> Empirically confirmed against the dev DB:
> `SELECT COUNT(*) FROM gdsconfigformcustomslave` returns 0 rows. No
> tenant on this DB has registered any per-tenant field overlay, so a
> worked example cannot be drawn from here. The item stays a real gap
> waiting on a populated production-tenant DB.

4. **A real example.** Find a tenant's actual `gdsconfigformcustomslave`
   rows in a populated deployment and use them as a worked example here.
