# 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 the BACK module that *edits* `gdsconfigformcustomslave` rows (one of the system-management screens — likely `界面显示内容配置` — needs verification by clicking through BACK). 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. > **Caveat.** Whether `gdsconfigformcustomslave` is populated depends on > the deployment — the table is wired into the framework, but a deployment > with no tenant-level field overrides will see it empty. The trace below > is code-derived; observe a populated deployment to validate end-to-end. ## 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`.** Most likely `界面显示内容配置` from the sidebar; confirm by clicking in BACK and checking which table the save endpoint writes to. 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.** Does setting `bVisible = false` on a `gdsconfigformcustomslave` row *hide* an existing base field (an override-to-remove pattern), or only suppress the override itself? Likely the former, but worth confirming. 4. **A real example.** Find a tenant's actual `gdsconfigformcustomslave` rows in a populated deployment and use them as a worked example here.