# 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 is on the [Maintainer Reference](../reference/maintainer/runtime.md)'s TODO list. ## Why it works without code changes The end-customer never asks an engineer for a new column. They open the BACK builder, add the row, the field appears in FROUNT for their tenant only. The system's other tenants are untouched. That single-codebase property is what xly's data-driven thesis ([Concepts → Thesis](../concepts/thesis.md)) buys — at the cost of the runtime cost of merging metadata on every request, plus the schema bloat of three customization tables that most forms never use. ## 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.