*/ final class FormValueObserver { /** @var array 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": } if (is_array($raw) && array_key_exists('value', $raw) && is_scalar($raw['value'])) { return (string) $raw['value']; } return null; } /** * @param mixed $raw * @return array */ 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); } }