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:
@@ -62,6 +62,7 @@ final class CrowdList extends Model
|
||||
public function persons(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Person::class, 'crowd_list_persons')
|
||||
->using(\App\Models\Pivots\CrowdListPerson::class)
|
||||
->withPivot('added_at', 'added_by_user_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ final class Event extends Model
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'event_user_roles')
|
||||
->using(\App\Models\Pivots\EventUserRole::class)
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Models\FormBuilder;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -12,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
final class FormSubmissionSectionStatus extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'form_submission_id',
|
||||
|
||||
@@ -4,18 +4,20 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models\FormBuilder;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* EAV storage row. Int PK for fast joins (ARCH §4.4). Typed columns are
|
||||
* populated by FormValueObserver based on field.value_storage_hint.
|
||||
* EAV storage row. Typed columns are populated by FormValueObserver based
|
||||
* on field.value_storage_hint.
|
||||
*/
|
||||
final class FormValue extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'form_submission_id',
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models\FormBuilder;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -16,6 +17,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
final class FormValueOption extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
|
||||
@@ -5,11 +5,14 @@ declare(strict_types=1);
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class MfaBackupCode extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'code_hash',
|
||||
|
||||
@@ -5,11 +5,14 @@ declare(strict_types=1);
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class MfaEmailCode extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'code',
|
||||
|
||||
@@ -56,6 +56,7 @@ final class Organisation extends Model
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'organisation_user')
|
||||
->using(\App\Models\Pivots\OrganisationUser::class)
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ final class Person extends Model
|
||||
public function crowdLists(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(CrowdList::class, 'crowd_list_persons')
|
||||
->using(\App\Models\Pivots\CrowdListPerson::class)
|
||||
->withPivot('added_at', 'added_by_user_id');
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class PersonSectionPreference extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
|
||||
19
api/app/Models/Pivots/CrowdListPerson.php
Normal file
19
api/app/Models/Pivots/CrowdListPerson.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Pivots;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
final class CrowdListPerson extends Pivot
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $table = 'crowd_list_persons';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
}
|
||||
19
api/app/Models/Pivots/EventPersonActivation.php
Normal file
19
api/app/Models/Pivots/EventPersonActivation.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Pivots;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
final class EventPersonActivation extends Pivot
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $table = 'event_person_activations';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
}
|
||||
19
api/app/Models/Pivots/EventUserRole.php
Normal file
19
api/app/Models/Pivots/EventUserRole.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Pivots;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
final class EventUserRole extends Pivot
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $table = 'event_user_roles';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
}
|
||||
19
api/app/Models/Pivots/OrganisationUser.php
Normal file
19
api/app/Models/Pivots/OrganisationUser.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Pivots;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
final class OrganisationUser extends Pivot
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $table = 'organisation_user';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
}
|
||||
@@ -72,6 +72,7 @@ final class User extends Authenticatable
|
||||
public function organisations(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Organisation::class, 'organisation_user')
|
||||
->using(\App\Models\Pivots\OrganisationUser::class)
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
@@ -79,6 +80,7 @@ final class User extends Authenticatable
|
||||
public function events(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Event::class, 'event_user_roles')
|
||||
->using(\App\Models\Pivots\EventUserRole::class)
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -11,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
final class UserOrganisationTag extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
|
||||
@@ -81,7 +81,10 @@ final class FormValueObserver
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('organisation_user');
|
||||
|
||||
Schema::create('organisation_user', function (Blueprint $table): void {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('role');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'organisation_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('organisation_user');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('event_user_roles');
|
||||
|
||||
Schema::create('event_user_roles', function (Blueprint $table): void {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUlid('event_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('role');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'event_id', 'role']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('event_user_roles');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('crowd_list_persons');
|
||||
|
||||
Schema::create('crowd_list_persons', function (Blueprint $table): void {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('crowd_list_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUlid('person_id')->constrained('persons')->cascadeOnDelete();
|
||||
$table->timestamp('added_at');
|
||||
$table->foreignUlid('added_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->unique(['crowd_list_id', 'person_id']);
|
||||
$table->index('person_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('crowd_list_persons');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('event_person_activations');
|
||||
|
||||
Schema::create('event_person_activations', function (Blueprint $table): void {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('event_id')->constrained('events')->cascadeOnDelete();
|
||||
$table->foreignUlid('person_id')->constrained('persons')->cascadeOnDelete();
|
||||
|
||||
$table->unique(['event_id', 'person_id']);
|
||||
$table->index('person_id');
|
||||
$table->index('event_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('event_person_activations');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('user_organisation_tags');
|
||||
|
||||
Schema::create('user_organisation_tags', function (Blueprint $table): void {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUlid('person_tag_id')->constrained()->cascadeOnDelete();
|
||||
$table->enum('source', ['self_reported', 'organiser_assigned']);
|
||||
$table->foreignUlid('assigned_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->enum('proficiency', ['beginner', 'experienced', 'expert'])->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamp('assigned_at');
|
||||
|
||||
$table->unique(['user_id', 'organisation_id', 'person_tag_id', 'source'], 'uot_user_org_tag_source_unique');
|
||||
$table->index(['user_id', 'organisation_id']);
|
||||
$table->index('person_tag_id');
|
||||
$table->index(['organisation_id', 'person_tag_id', 'proficiency'], 'uot_org_tag_proficiency_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_organisation_tags');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('person_section_preferences');
|
||||
|
||||
Schema::create('person_section_preferences', function (Blueprint $table): void {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('person_id')->constrained('persons')->cascadeOnDelete();
|
||||
$table->foreignUlid('festival_section_id')->constrained('festival_sections')->cascadeOnDelete();
|
||||
$table->tinyInteger('priority');
|
||||
|
||||
$table->unique(['person_id', 'festival_section_id'], 'psp_person_section_unique');
|
||||
$table->index(['festival_section_id', 'priority']);
|
||||
$table->index('person_id', 'psp_person_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('person_section_preferences');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('mfa_backup_codes');
|
||||
|
||||
Schema::create('mfa_backup_codes', function (Blueprint $table): void {
|
||||
$table->ulid('id')->primary();
|
||||
$table->ulid('user_id');
|
||||
$table->string('code_hash', 64);
|
||||
$table->boolean('used')->default(false);
|
||||
$table->timestamp('used_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
|
||||
$table->index(['user_id', 'used']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('mfa_backup_codes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('mfa_email_codes');
|
||||
|
||||
Schema::create('mfa_email_codes', function (Blueprint $table): void {
|
||||
$table->ulid('id')->primary();
|
||||
$table->ulid('user_id');
|
||||
$table->string('code', 6);
|
||||
$table->timestamp('expires_at');
|
||||
$table->boolean('used')->default(false);
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
|
||||
$table->index(['user_id', 'code', 'used', 'expires_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('mfa_email_codes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('form_submission_section_statuses');
|
||||
|
||||
Schema::create('form_submission_section_statuses', function (Blueprint $table): void {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('form_submission_id')
|
||||
->constrained('form_submissions')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignUlid('form_schema_section_id')
|
||||
->constrained('form_schema_sections')
|
||||
->cascadeOnDelete();
|
||||
$table->string('status', 30);
|
||||
$table->timestamp('submitted_at')->nullable();
|
||||
$table->foreignUlid('reviewed_by_user_id')->nullable()
|
||||
->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('reviewed_at')->nullable();
|
||||
$table->text('review_notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(
|
||||
['form_submission_id', 'form_schema_section_id'],
|
||||
'fsss_submission_section_unique'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('form_submission_section_statuses');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
// A-10 form_values and A-11 form_value_options are handled in one migration
|
||||
// because form_value_options.form_value_id is a FK to form_values.id —
|
||||
// both sides of the FK must be ULID together.
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Drop in FK-dependency order: child first, then parent.
|
||||
Schema::dropIfExists('form_value_options');
|
||||
Schema::dropIfExists('form_values');
|
||||
|
||||
Schema::create('form_values', function (Blueprint $table): void {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('form_submission_id')
|
||||
->constrained('form_submissions')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignUlid('form_field_id')
|
||||
->constrained('form_fields')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->json('value');
|
||||
$table->string('value_indexed', 255)->nullable();
|
||||
$table->decimal('value_number', 15, 4)->nullable();
|
||||
$table->date('value_date')->nullable();
|
||||
$table->boolean('value_bool')->nullable();
|
||||
$table->boolean('value_anonymised')->default(false);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(
|
||||
['form_submission_id', 'form_field_id'],
|
||||
'fv_submission_field_unique'
|
||||
);
|
||||
$table->index(['form_field_id', 'value_indexed'], 'fv_field_indexed_idx');
|
||||
$table->index(['form_field_id', 'value_number'], 'fv_field_number_idx');
|
||||
$table->index(['form_field_id', 'value_date'], 'fv_field_date_idx');
|
||||
});
|
||||
|
||||
Schema::create('form_value_options', function (Blueprint $table): void {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('form_value_id')
|
||||
->constrained('form_values')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignUlid('form_field_id')
|
||||
->constrained('form_fields')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignUlid('form_submission_id')
|
||||
->constrained('form_submissions')
|
||||
->cascadeOnDelete();
|
||||
$table->string('option_value', 255);
|
||||
|
||||
$table->index(['form_field_id', 'option_value'], 'fvo_field_option_idx');
|
||||
$table->index('form_submission_id', 'fvo_submission_idx');
|
||||
$table->index('form_value_id', 'fvo_value_idx');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('form_value_options');
|
||||
Schema::dropIfExists('form_values');
|
||||
}
|
||||
};
|
||||
@@ -25,6 +25,7 @@ use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DevSeeder extends Seeder
|
||||
{
|
||||
@@ -893,6 +894,7 @@ class DevSeeder extends Seeder
|
||||
foreach ($activations as [$person, $events]) {
|
||||
foreach ($events as $event) {
|
||||
DB::table('event_person_activations')->insert([
|
||||
'id' => (string) Str::ulid(),
|
||||
'event_id' => $event->id,
|
||||
'person_id' => $person->id,
|
||||
]);
|
||||
@@ -909,6 +911,7 @@ class DevSeeder extends Seeder
|
||||
$selectedEvents = collect($subEventIds)->shuffle()->take($numEvents);
|
||||
foreach ($selectedEvents as $eventId) {
|
||||
DB::table('event_person_activations')->insert([
|
||||
'id' => (string) Str::ulid(),
|
||||
'event_id' => $eventId,
|
||||
'person_id' => $person->id,
|
||||
]);
|
||||
|
||||
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