04-custom-field.md 11.1 KB

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:

  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:

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 — add a section on the override path: same recipe, different table.
  • Maintainer: the runtime — 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).

  1. Trace the merge code. CLOSED — the merge happens in Java at BusinessBaseServiceImpl.java:246-248: it calls businessGdsconfigformsService.getFormSlaveData(map) then getFormCustomSlaveData(map) and addAlls 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.
  2. 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.

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