fix(forms): gate value_indexed population on is_filterable

FormValueObserver: value_indexed is filter-driven per ARCH §4.4, not
hint-driven. Populating it for every string-hint field produced dead
weight in the partial index and made FilterQueryBuilder logic murkier.

Behaviour after fix:
  hint=string,  is_filterable=true  → populate value_indexed
  hint=string,  is_filterable=false → leave null
  hint=number/date/bool, any filterable → populate typed column (unchanged)
  hint=json, any filterable → leave typed columns null (unchanged)

value_number / value_date / value_bool remain hint-driven — they serve
display and sorting beyond filtering. Only value_indexed is gated.

VerifyFormsDataIntegrity: "value_indexed set on non-filterable field"
is now a FAIL (was WARN) — it means the observer didn't run correctly,
which is a real integrity issue.

Observer tests: split the old "string hint populates value_indexed"
case into filterable/non-filterable pair. Full suite 911/911.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 15:28:15 +02:00
parent 021a3cd079
commit ccdfd5b77b
3 changed files with 28 additions and 15 deletions

View File

@@ -36,24 +36,23 @@ final class FormValueObserver
$this->resetTypedColumns($value);
// value stores the canonical payload (always JSON). Typed columns
// are derived from it based on the field's storage hint.
// are derived from it per field.value_storage_hint, with one
// exception: value_indexed is filter-driven (§4.4), not hint-driven,
// so populate it ONLY when field.is_filterable = true. Keeps the
// partial index lean and simplifies FilterQueryBuilder (S4).
$raw = $value->value;
$scalar = $this->extractScalar($raw);
match ($field->value_storage_hint) {
FormValueStorageHint::STRING => $value->value_indexed = $this->truncateIndexed($scalar),
FormValueStorageHint::STRING => null, // handled below, filter-gated
FormValueStorageHint::NUMBER => $value->value_number = is_numeric($scalar) ? (float) $scalar : null,
FormValueStorageHint::DATE => $value->value_date = $this->castDate($scalar),
FormValueStorageHint::BOOL => $value->value_bool = $scalar === null ? null : (bool) $scalar,
FormValueStorageHint::JSON => null,
};
// Single-value filterable fields: ensure value_indexed is populated
// regardless of storage hint, so filter queries hit one index.
if ($field->is_filterable && ! $this->isMultiValueType($field)) {
if ($value->value_indexed === null) {
$value->value_indexed = $this->truncateIndexed($scalar);
}
$value->value_indexed = $this->truncateIndexed($scalar);
}
}