Skip to content

Typed Fields Engine Developer Guide

Ed Mozley edited this page Aug 20, 2026 · 1 revision

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).


1. πŸ“ The files involved

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

2. πŸ”‘ Why this exists at all

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.


3. What the engine owns, and what it deliberately doesn't

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.


4. The canonical definition shape

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 both type === 'ref' and ref_kind === 'cmdb_object'. If that test ever stops matching, cross-company reference checks stop running silently.


5. πŸ”‘ Modes, not types

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.

Storage classes

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.


6. The schema descriptor

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.


7. The reference registry

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".


8. πŸ”‘ Absent is not "no"

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 returning null for 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.


9. Required, and the create/update asymmetry

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.


10. Reading, and the N+1 rule

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.


11. Adding a third consumer

  1. 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.
  2. Write a defsFor…() that returns canonical definitions keyed by field key.
  3. Write a valueSchema() descriptor.
  4. Call TypedFields::checkRequired() then writeValues(); call readValues() batched.
  5. Keep every authorisation check in your own service.
  6. Register any new reference kind in typed_fields.php, not in your module.

12. πŸ§ͺ What the tests actually prove

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 checkout and the two outputs diffed to nothing. A passing test proves the new code works; only the diff proves it works the same.

⚠️ Both services open their own transactions, so a test can't wrap one. These tests create only zz-prefixed rows and sweep them before and after.


13. Known quirks

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.


See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally