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>
This commit is contained in:
222
api/tests/Feature/Schema/UlidPrimaryKeyTest.php
Normal file
222
api/tests/Feature/Schema/UlidPrimaryKeyTest.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Schema;
|
||||
|
||||
use App\Models\CrowdList;
|
||||
use App\Models\CrowdType;
|
||||
use App\Models\Event;
|
||||
use App\Models\FestivalSection;
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\FormBuilder\FormSchemaSection;
|
||||
use App\Models\FormBuilder\FormSubmission;
|
||||
use App\Models\FormBuilder\FormSubmissionSectionStatus;
|
||||
use App\Models\FormBuilder\FormValue;
|
||||
use App\Models\FormBuilder\FormValueOption;
|
||||
use App\Models\MfaBackupCode;
|
||||
use App\Models\MfaEmailCode;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Person;
|
||||
use App\Models\PersonSectionPreference;
|
||||
use App\Models\PersonTag;
|
||||
use App\Models\Pivots\EventPersonActivation;
|
||||
use App\Models\User;
|
||||
use App\Models\UserOrganisationTag;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* WS-4 Commit 1 — addendum Q1 coverage.
|
||||
*
|
||||
* Verifies that the eleven Category-A tables use Crockford ULID primary
|
||||
* keys, not integer auto-increment. Split into two groups:
|
||||
* - Model-backed tables (A-05/06/09/10/11): check HasUlids + ULID shape
|
||||
* + Route::bind resolves via implicit model binding.
|
||||
* - Pure pivots (A-01..A-04): DB-level check that two inserts produce
|
||||
* distinct, 26-char, Crockford-sortable ULIDs.
|
||||
*/
|
||||
final class UlidPrimaryKeyTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const CROCKFORD_ULID_PATTERN = '/^[0-9A-HJKMNP-TV-Z]{26}$/i';
|
||||
|
||||
/**
|
||||
* @dataProvider modelBackedAcategoryTables
|
||||
*/
|
||||
public function test_model_uses_has_ulids_and_generates_crockford_ulid(string $modelClass): void
|
||||
{
|
||||
$this->assertContains(
|
||||
HasUlids::class,
|
||||
class_uses_recursive($modelClass),
|
||||
$modelClass.' must use HasUlids trait (addendum Q1)'
|
||||
);
|
||||
|
||||
$model = (new $modelClass());
|
||||
$this->assertSame(
|
||||
'string',
|
||||
$model->getKeyType(),
|
||||
$modelClass.'::getKeyType() must be "string" for ULID keys'
|
||||
);
|
||||
|
||||
// Creating-event-driven ULID generation
|
||||
$instance = new $modelClass();
|
||||
$instance->setAttribute($instance->getKeyName(), null);
|
||||
$reflection = new \ReflectionClass($instance);
|
||||
// Simulate the creating hook exactly as Eloquent would.
|
||||
$method = $reflection->getMethod('newUniqueId');
|
||||
$generated = $method->invoke($instance);
|
||||
$this->assertMatchesRegularExpression(
|
||||
self::CROCKFORD_ULID_PATTERN,
|
||||
(string) $generated,
|
||||
$modelClass.'::newUniqueId() must return a 26-char Crockford ULID'
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, array{0: class-string}> */
|
||||
public static function modelBackedAcategoryTables(): array
|
||||
{
|
||||
return [
|
||||
'A-05 UserOrganisationTag' => [UserOrganisationTag::class],
|
||||
'A-06 PersonSectionPreference' => [PersonSectionPreference::class],
|
||||
'A-09 FormSubmissionSectionStatus' => [FormSubmissionSectionStatus::class],
|
||||
'A-10 FormValue' => [FormValue::class],
|
||||
'A-11 FormValueOption' => [FormValueOption::class],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_route_model_binding_resolves_a_category_models(): void
|
||||
{
|
||||
$organisation = Organisation::factory()->create();
|
||||
$event = Event::factory()->for($organisation)->create();
|
||||
$festivalSection = FestivalSection::factory()->for($event)->create();
|
||||
$person = Person::factory()->for($event)->create();
|
||||
$tag = PersonTag::factory()->for($organisation)->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$uotag = UserOrganisationTag::create([
|
||||
'user_id' => $user->id,
|
||||
'organisation_id' => $organisation->id,
|
||||
'person_tag_id' => $tag->id,
|
||||
'source' => 'self_reported',
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
$preference = PersonSectionPreference::create([
|
||||
'person_id' => $person->id,
|
||||
'festival_section_id' => $festivalSection->id,
|
||||
'priority' => 1,
|
||||
]);
|
||||
|
||||
// Route::bind resolves via implicit model binding because
|
||||
// getRouteKeyName() defaults to the primary key, which is now a ULID.
|
||||
$this->assertEquals($uotag->id, $uotag->resolveRouteBinding($uotag->id)?->id);
|
||||
$this->assertEquals($preference->id, $preference->resolveRouteBinding($preference->id)?->id);
|
||||
}
|
||||
|
||||
public function test_pure_pivot_tables_generate_distinct_sortable_crockford_ulids(): void
|
||||
{
|
||||
$organisation = Organisation::factory()->create();
|
||||
$event = Event::factory()->for($organisation)->create();
|
||||
$crowdType = CrowdType::factory()->for($organisation)->create();
|
||||
$person1 = Person::factory()->for($event)->for($crowdType)->create();
|
||||
$person2 = Person::factory()->for($event)->for($crowdType)->create();
|
||||
|
||||
// A-04 event_person_activations is seeded via DB::table() in the
|
||||
// dev seeder and attach-less in production; insert two rows and
|
||||
// assert the resulting ULIDs.
|
||||
$idA = (string) Str::ulid();
|
||||
$idB = (string) Str::ulid();
|
||||
|
||||
DB::table('event_person_activations')->insert([
|
||||
['id' => $idA, 'event_id' => $event->id, 'person_id' => $person1->id],
|
||||
['id' => $idB, 'event_id' => $event->id, 'person_id' => $person2->id],
|
||||
]);
|
||||
|
||||
$rows = DB::table('event_person_activations')->orderBy('id')->pluck('id');
|
||||
|
||||
$this->assertCount(2, $rows);
|
||||
$this->assertNotEquals($rows[0], $rows[1], 'Pivot ULIDs must be distinct');
|
||||
foreach ($rows as $id) {
|
||||
$this->assertMatchesRegularExpression(
|
||||
self::CROCKFORD_ULID_PATTERN,
|
||||
(string) $id,
|
||||
'Pure pivot rows must carry 26-char Crockford ULIDs'
|
||||
);
|
||||
}
|
||||
$this->assertSame(
|
||||
[$idA, $idB],
|
||||
$rows->toArray(),
|
||||
'ULIDs must sort lexicographically by creation order'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_organisation_user_pivot_auto_generates_ulid_on_attach(): void
|
||||
{
|
||||
// A-01 organisation_user is driven by Eloquent belongsToMany
|
||||
// ->using(OrganisationUser::class). attach() must produce a ULID
|
||||
// via the Pivot's HasUlids trait.
|
||||
$organisation = Organisation::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->organisations()->attach($organisation, ['role' => 'org_admin']);
|
||||
|
||||
$pivotId = DB::table('organisation_user')
|
||||
->where('user_id', $user->id)
|
||||
->where('organisation_id', $organisation->id)
|
||||
->value('id');
|
||||
|
||||
$this->assertIsString($pivotId);
|
||||
$this->assertMatchesRegularExpression(
|
||||
self::CROCKFORD_ULID_PATTERN,
|
||||
(string) $pivotId
|
||||
);
|
||||
}
|
||||
|
||||
public function test_form_value_option_fk_chain_resolves_with_ulids(): void
|
||||
{
|
||||
// A-10/A-11 coupling: form_value_options.form_value_id FK must be
|
||||
// ULID-typed after the combined migration. Insert a full chain and
|
||||
// verify the join path works end-to-end.
|
||||
$organisation = Organisation::factory()->create();
|
||||
$schema = FormSchema::factory()->for($organisation)->create();
|
||||
$section = FormSchemaSection::factory()->for($schema, 'schema')->create();
|
||||
$field = FormField::factory()->for($section, 'section')->for($schema, 'schema')->create();
|
||||
$submission = FormSubmission::factory()->for($schema, 'schema')->create();
|
||||
|
||||
$value = FormValue::create([
|
||||
'form_submission_id' => $submission->id,
|
||||
'form_field_id' => $field->id,
|
||||
'value' => ['value' => 'x'],
|
||||
'value_anonymised' => false,
|
||||
]);
|
||||
|
||||
$option = FormValueOption::create([
|
||||
'form_value_id' => $value->id,
|
||||
'form_field_id' => $field->id,
|
||||
'form_submission_id' => $submission->id,
|
||||
'option_value' => 'x',
|
||||
]);
|
||||
|
||||
$this->assertMatchesRegularExpression(self::CROCKFORD_ULID_PATTERN, (string) $value->id);
|
||||
$this->assertMatchesRegularExpression(self::CROCKFORD_ULID_PATTERN, (string) $option->id);
|
||||
$this->assertSame($value->id, $option->form_value_id);
|
||||
}
|
||||
|
||||
public function test_pivot_model_class_uses_has_ulids(): void
|
||||
{
|
||||
// The 4 pure-pivot tables are wired via Pivot subclasses with
|
||||
// HasUlids — addendum Q1 literal text says "no model required",
|
||||
// but Laravel's BelongsToMany::attach() cannot auto-generate the
|
||||
// ULID without a Pivot class. Minimal Pivot models were added.
|
||||
$this->assertContains(
|
||||
HasUlids::class,
|
||||
class_uses_recursive(EventPersonActivation::class),
|
||||
'Pivot classes wired to pure pivots must use HasUlids'
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user