-
Notifications
You must be signed in to change notification settings - Fork 15
Typed Fields Engine Developer Guide
The shared machinery behind user-defined, strongly-typed fields. One definition says "this thing has a field called Warranty Expiry, and it is a date"; one value row holds the answer in a column matching its type. This file owns everything between those two facts.
Two consumers today β the CMDB's class properties and custom asset fields β and adding a third should be a descriptor and a query, not a new engine.
The siblings are Custom asset fields (the assets consumer) and Asset import (the feature built on top). Design doc: docs/design/flexible-asset-fields.md (local only β docs/design/ is gitignored).
Colour key: ποΈ schema Β· βοΈ engine Β· π API Β· π₯οΈ UI Β· π§ͺ tests
| π¨ | File | What it does |
|---|---|---|
| βοΈ | includes/typed_fields.php |
TypedFields β the engine. Types, coercion, validation, required-checking, the batched reader, and the reference-kind registry |
| βοΈ | includes/services/cmdb.php |
consumer #1. classDefs() maps class properties to the canonical shape; writeProperties() / checkRequired() delegate |
| βοΈ | includes/services/asset_fields.php |
consumer #2 β see its own guide |
| π§ͺ | tests/cmdb-typed-fields.php |
24 assertions proving the CMDB behaves identically after the extraction |
FreeITSM already ran a typed-EAV field system twice before this: cmdb_class_properties + cmdb_object_properties, and form_fields + form_submission_data. Recording assets that aren't Windows computers needed a third.
The number that decided it: 17 files read cmdb_class_properties, and exactly one file writes property values. So the write path could be extracted with no migration, no reader touched, and no schema change at all.
The rule the extraction followed: the first consumer must be the EXISTING one. If assets had been the only user at the start, the result would have been a second engine with a generic name, quietly growing asset-shaped assumptions. Slice one changed nothing on screen and existed purely to prove the engine was general.
Owns: the type system and its storage-class mapping; coercion; validation; required-checking with the create/update asymmetry; the batched read; the reference registry; the three-state rule.
Doesn't own β and these are not oversights:
Which fields a thing has. Each module answers that with its own query and hands the result in as canonical definitions. The CMDB resolves them from a class; Assets resolves them from the field sets on a type plus any on the individual asset. Generalising that query would force one attachment model on both.
Authorisation. Multi-tenancy gates, module access and actor scope stay in the calling service. CmdbService::assertObjectRefsInCompany() is the live example: the engine checks a reference exists and points at the right kind of thing; only the module decides whether the actor may see it.
β οΈ A security rule living in a generic layer degrades silently, and not toward safety. This one is worth restating every time somebody is tempted to "tidy up" by moving the tenancy check down a layer.
Turning a reference into a name. refLabels() exists but is opt-in and caller-driven, because resolving a reference reads another table and whether the caller may see those rows is the caller's question.
Every consumer converts its own rows into this before handing them over:
[
'id' => int, // the definition row's id
'key' => string, // stable machine key (property_key / field_key)
'label' => string, // display name, used in error messages
'type' => string, // one of TypedFields::TYPES
'required' => bool,
'config' => array, // per-type settings; [] where a module has none
'ref_kind' => ?string, // 'ref' only: which registry entry
'ref_target' => ?int, // 'ref' only: optional narrowing (e.g. a class id)
]The CMDB's object_ref becomes type ref with ref_kind = 'cmdb_object' and ref_target = target_class_id.
β οΈ CmdbService::assertObjectRefsInCompany()reads the canonical shape, not the raw column β it tests bothtype === 'ref'andref_kind === 'cmdb_object'. If that test ever stops matching, cross-company reference checks stop running silently.
A presentational variant is a MODE inside config. A different storage class is a TYPE.
A field's type can't be changed once values exist, so anything somebody might plausibly want to flip later must be a mode:
| Looks like two types | Actually |
|---|---|
| text / textarea |
text + config.multiline
|
| date / time / datetime |
date + config.date_mode
|
| integer / decimal / currency |
number + config.decimals, config.unit
|
FormsService::DATE_MODES learned this first and wrote down why: picking the wrong one otherwise means deleting the field and stranding every answer given to it.
| Type | Stored as |
|---|---|
text, dropdown, url, email
|
text |
number |
number |
date |
date |
boolean |
boolean |
ref |
ref |
The schema descriptor maps a storage class to an actual column, so two consumers can name their columns differently β the CMDB has value_object_id where assets have value_ref_id β without the engine caring.
Where a consumer's values live:
[
'value_table' => 'cmdb_object_properties',
'owner_column' => 'object_id',
'def_column' => 'property_id',
'columns' => [ storage class => column name ],
'self_kind' => 'cmdb_object', // see below
'options' => callable(PDO, int $defId): string[],
'unknown_hint' => 'See GET /cmdb/classes/12.',
]self_kind says which reference kind counts as "the same table as the owner", so the no-self-reference rule fires for a CI pointing at itself and not for an asset whose linked person happens to share its id. A bare $refId === $ownerId test would have introduced exactly that bug the first time a user field was added.
ref fields point at something else. Rather than a growing switch statement, kinds are registered β the same shape as the entity registry in includes/documents.php:
TypedFields::registerRefKind('cmdb_object', [
'label' => 'configuration item',
'target_label' => 'class',
'exists' => fn(PDO $conn, int $id) => /* the target discriminator, or false */,
'labels' => fn(PDO $conn, array $ids) => [id => label],
]);Three kinds today: cmdb_object (discriminator = class id), asset (discriminator = asset type id), user (no discriminator).
exists() returns the discriminator, or false if there's no such row. A kind with nothing to narrow on β user β returns 0, which is a real "found, nothing to narrow" rather than a not-found. 0 === false is false under strict comparison, which is what makes that work.
β οΈ A handler answers "is this a real row of the right kind?" and nothing more. It must never answer "may this actor see it".
The single most important behavioural rule in the engine, and it runs all the way to the UI.
No value row means NOT SET. Not empty string, not false, not zero. Ten televisions where three are smart get three rows each for the smart ones and nothing at all for the other seven.
That means every consumer has to keep three states apart:
-
readValues()omits a field entirely rather than returningnullfor it - the REST API leaves the key out rather than sending
null - the asset table writes a value onto a row only if it exists, so the shared data-table's
(empty)filter bucket stays distinct - a yes/no control offers Yes / No / Not set, three options
Fold "not set" into "no" anywhere and you get a confident wrong answer β the same class of bug as a Watchtower counter reporting zero because a name matched nothing.
Required is enforced on create, and on update only for fields actually being written.
Without the asymmetry, ticking "required" on an existing field makes every older record unsaveable β including by somebody editing an unrelated field. Pre-existing violations are surfaced by an audit view instead. This is the answer docs/cmdb.md had left open, and it applies unchanged to assets.
readValues(PDO, $schema, $defs, array $ownerIds) returns [ownerId => [key => value]] from one query.
There is deliberately no per-owner variant. A list of 500 assets must cost one query, not 500. If you find yourself calling this in a loop, collect the ids and call it once.
The one place that knowingly breaks this is
apiSerializeAsset()in REST v1, because the serializer is per-asset by construction. It's flagged in the code as the line to change if list latency ever matters.
- Create a definition table and a value table. Keep them separate β a single polymorphic value table would need a sweep instead of a foreign key, which is the lesson from
documents. - Write a
defsForβ¦()that returns canonical definitions keyed by field key. - Write a
valueSchema()descriptor. - Call
TypedFields::checkRequired()thenwriteValues(); callreadValues()batched. - Keep every authorisation check in your own service.
- Register any new reference kind in
typed_fields.php, not in your module.
tests/cmdb-typed-fields.php β 24 assertions covering every branch that moved: each type landing in its own column, clearing, all seven validation messages byte-for-byte (they're the REST API's published error bodies), the required asymmetry, and both accepted input shapes.
π The evidence that mattered was differential, not the green tick. The same test file was run against the pre-refactor code via
git checkoutand the two outputs diffed to nothing. A passing test proves the new code works; only the diff proves it works the same.
zz-prefixed rows and sweep them before and after.
A nonexistent CMDB reference reports differently by install. On a multi-company install assertObjectRefsInCompany() runs first and says "Object not found." β deliberately, since it must not confirm whether another company's CI exists. On a single-company install that check returns early and the engine's own message appears. Both correct; confirmed present before the extraction.
cmdb_object_properties has no index on any value_* column. Fine at its scale, and the asset value table doesn't repeat it β see the composite indexes in the assets guide.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)