Files
crewli/api/app/Observers/FormBuilder/FormValueObserver.php
bert.hausmans a92ddc48ec refactor(schema): migrate eleven pivot/EAV tables to ULID per addendum Q1
Retires the "integer AI PK for join performance" exception documented
in earlier migrations and SCHEMA.md §3.5.11 Rule 1. Every business and
pivot table now uses ULID primary keys, per
/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md Q1.

Tables migrated (WS-1 A-01 through A-11):
- Pure pivots: organisation_user, event_user_roles, crowd_list_persons,
  event_person_activations
- Model-backed: user_organisation_tags, person_section_preferences,
  mfa_backup_codes, mfa_email_codes, form_submission_section_statuses,
  form_values, form_value_options

Migration pattern: one new migration per table (plus one combined for
the form_values / form_value_options FK pair), timestamped today,
dropping + recreating with the new ULID PK. Pre-launch — no backfill
required. Original migrations remain in place; the new migrations
apply in timestamp order for a clean schema history.

Pivot model correction (addendum drift):
The addendum's "no model required for pure pivots" reading did not
account for Laravel's BelongsToMany::attach() — it cannot auto-generate
a pivot ULID without a Pivot subclass. Minimal Pivot classes under
app/Models/Pivots/ (OrganisationUser, EventUserRole, CrowdListPerson,
EventPersonActivation) carry HasUlids so attach() works. The six
belongsToMany relations (User.organisations / .events, Organisation.users,
Event.users, CrowdList.persons, Person.crowdLists) now ->using(...) the
appropriate Pivot class. DB::table()->insert() on event_person_activations
in DevSeeder populates the ULID inline via Str::ulid(). FormValueObserver
uses bulk FormValueOption::insert() which bypasses model events — ULIDs
are now generated inline there too.

Docs:
- SCHEMA.md §3.5.11 Rule 1 rewritten to mandate ULID on pivots too, with
  legacy note citing the addendum.
- All eleven table entries updated from "int AI PK" to "ULID PK" with
  addendum Q1 references.
- form_values and form_submission_section_statuses prose blocks updated
  to drop the retired ARCH §4.4 / "high-volume pivot" rationale.
- form_value_options.form_value_id column type corrected from
  "int FK" to "ULID FK".

Tests: tests/Feature/Schema/UlidPrimaryKeyTest.php covers HasUlids trait
presence, ULID shape + 26-char Crockford pattern, Route::bind resolution,
distinct + sortable pivot ULIDs, attach() auto-generation on pure pivots,
and the A-10/A-11 FK chain. 10 tests / 28 new assertions. Full suite:
977 passed (2662 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:38:08 +02:00

195 lines
6.0 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;
}
// Bulk insert bypasses Eloquent model events, so HasUlids does not
// populate the primary key. Generate ULIDs inline.
$rows = array_map(fn (string $opt): array => [
'id' => (string) Str::ulid(),
'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);
}
}