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>
195 lines
6.0 KiB
PHP
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);
|
|
}
|
|
}
|