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>
223 lines
8.6 KiB
PHP
223 lines
8.6 KiB
PHP
<?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'
|
|
);
|
|
}
|
|
}
|