Files
crewli/api/app/Observers/FormBuilder/FormValueObserver.php
bert.hausmans ccdfd5b77b 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>
2026-04-17 15:28:15 +02:00

192 lines
5.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Observers\FormBuilder;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormValueStorageHint;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormValue;
use App\Models\FormBuilder\FormValueOption;
use Illuminate\Support\Str;
/**
* Populates typed columns (value_indexed / value_number / value_date /
* value_bool) on FormValue upsert, and rebuilds the form_value_options
* pivot for multi-value filterable fields. See ARCH §7.2.
*
* The caller SHOULD eager-load the related FormField; we memoise on the
* observer instance to avoid N+1 when saving many values in one batch.
*
* @var array<string, FormField|null>
*/
final class FormValueObserver
{
/** @var array<string, FormField|null> Memoised field lookups for this observer lifetime. */
private array $fieldCache = [];
public function saving(FormValue $value): void
{
$field = $this->resolveField($value);
if ($field === null) {
return;
}
$this->resetTypedColumns($value);
// value stores the canonical payload (always JSON). Typed columns
// 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 => 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,
};
if ($field->is_filterable && ! $this->isMultiValueType($field)) {
$value->value_indexed = $this->truncateIndexed($scalar);
}
}
public function saved(FormValue $value): void
{
$field = $this->resolveField($value);
if ($field === null) {
return;
}
if (! $field->is_filterable) {
// Not filterable — ensure no stale pivot rows linger.
FormValueOption::where('form_value_id', $value->id)->delete();
return;
}
if (! $this->isMultiValueType($field)) {
return;
}
FormValueOption::where('form_value_id', $value->id)->delete();
$options = $this->extractOptions($value->value);
if ($options === []) {
return;
}
$rows = array_map(fn (string $opt): array => [
'form_value_id' => $value->id,
'form_field_id' => $value->form_field_id,
'form_submission_id' => $value->form_submission_id,
'option_value' => Str::limit($opt, 255, ''),
], $options);
FormValueOption::insert($rows);
}
public function deleted(FormValue $value): void
{
// Cascade handles FK delete at DB layer, but pivot rows without
// cascade-parent are cheap to clean explicitly.
FormValueOption::where('form_value_id', $value->id)->delete();
}
private function resolveField(FormValue $value): ?FormField
{
if ($value->relationLoaded('field')) {
return $value->getRelation('field');
}
$key = (string) $value->form_field_id;
if (! array_key_exists($key, $this->fieldCache)) {
$this->fieldCache[$key] = FormField::query()->find($value->form_field_id);
}
return $this->fieldCache[$key];
}
private function resetTypedColumns(FormValue $value): void
{
$value->value_indexed = null;
$value->value_number = null;
$value->value_date = null;
$value->value_bool = null;
}
/**
* @param mixed $raw
*/
private function extractScalar($raw): ?string
{
if ($raw === null || $raw === []) {
return null;
}
if (is_scalar($raw)) {
return (string) $raw;
}
// Conventional shape: { "value": <scalar> }
if (is_array($raw) && array_key_exists('value', $raw) && is_scalar($raw['value'])) {
return (string) $raw['value'];
}
return null;
}
/**
* @param mixed $raw
* @return array<int, string>
*/
private function extractOptions($raw): array
{
if (is_array($raw)) {
// MULTISELECT / CHECKBOX_LIST: list of scalars OR { value: [...] }
if (array_is_list($raw)) {
return array_values(array_map(fn ($v) => (string) $v, array_filter($raw, 'is_scalar')));
}
if (array_key_exists('value', $raw) && is_array($raw['value'])) {
return array_values(array_map(fn ($v) => (string) $v, array_filter($raw['value'], 'is_scalar')));
}
}
return [];
}
private function truncateIndexed(?string $value): ?string
{
if ($value === null) {
return null;
}
if (mb_strlen($value) > 255) {
return mb_substr($value, 0, 255);
}
return $value;
}
private function castDate(?string $value): ?string
{
if ($value === null || $value === '') {
return null;
}
$ts = strtotime($value);
return $ts === false ? null : date('Y-m-d', $ts);
}
private function isMultiValueType(FormField $field): bool
{
return in_array($field->field_type, [
FormFieldType::MULTISELECT->value,
FormFieldType::CHECKBOX_LIST->value,
FormFieldType::TAG_PICKER->value,
], true);
}
}