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 |
per tenant (sBrandsId + sSubsidiaryId) |
form-master — overrides sSqlStr / sWhere / sOrder for the whole form |
gdsconfigformcustomslave |
per tenant | form-slave — adds, hides, or replaces individual fields on the form |
gdsconfigformuserslave |
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:
- Open
界面显示内容配置in BACK (gdsmodule.sId=11,/jmnrpz). Its third panel writes togdsconfigformcustomslavevia the form-master atsId=19211681019715596285250620— verified live. See "Open verification items" item 1 below for the per-panel mapping. - Add a new row with:
-
sParentId= the form'ssId(same form the base slaves point to) -
sName = 'sInternalCode'(the field's column name) sChinese = '客户内部编码'-
sControlName = '文本框'(text input) -
sBrandsId+sSubsidiaryId= the tenant's IDs
-
- 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:
gdsconfigformcustomslaveis currently empty inxlyweberp_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— joinsgdsconfigformmasterwith the basegdsconfigformslave. Always loaded. -
gdsconfigformcustomslavemasterview— joinsgdsconfigformmasterwith the customgdsconfigformcustomslave. 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
gdsconfigformcustomslaverows 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
gdsconfigformcustomslavehas 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+gdsconfigformuserslaverows for both tenants. The merge logic inBusinessBaseServiceImplis 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 — add a section on the override path: same recipe, different table.
-
Maintainer: the runtime — the
merge-three-layers procedure happens inside
BusinessBaseServiceImplandBusinessGdsconfigformsServiceImpl. Worth a dedicated paragraph.
Open verification items
-
Find the live BACK page that editsCLOSED — confirmedgdsconfigformcustomslave.界面显示内容配置(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).
-
Trace the merge code.CLOSED — the merge happens in Java atBusinessBaseServiceImpl.java:246-248: it callsbusinessGdsconfigformsService.getFormSlaveData(map)thengetFormCustomSlaveData(map)andaddAlls them into oneslaveList. 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. -
CLOSED — both, at different layers. InbVisible = falsesemantics — hide-base or suppress-override?BusinessGdsconfigformsServiceImpl.java:413-433, when agdsconfigformcustomslaverow matches a basegdsconfigformslaverow bysControlNameorsName, the customslave row replaces the base row entirely (sList.removeAll(_cstlist); sList.addAll(_cList);), sobVisible=falseon 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'sbVisibleis true and the merged row'sbVisibleis true, the user'siFitWidth/iOrderapply; otherwise line 464 explicitly setscmap.put("bVisible", false)on the merged row — hiding it from that user only. SobVisible=falsedoes 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 gdsconfigformcustomslavereturns 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. A real example. Find a tenant's actual
gdsconfigformcustomslaverows in a populated deployment and use them as a worked example here.