From 190c500d86fc5334d1ca63c06e031bff2b584848 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Wed, 29 Apr 2026 11:48:51 +0200 Subject: [PATCH] fix: align schema with HasUuids design + add reusable MorphAliasRegistry The package's models (Permission, PermissionMember, Role, RoleMember, Access, RequiredAccess) all use HasUuids but the published create migrations created bigint columns. Every insert blew up in production with 'Incorrect integer value: for column id'. Migrations - create_blax_role_tables: uuid PK + uuidMorphs throughout - create_blax_access_table: uuid PK + uuidMorphs/nullableUuidMorphs - create_required_accesses_table: uuid PK + uuidMorphs - add_source_to_accesses_table: nullableUuidMorphs Two upgrade migrations convert in-place for hosts with existing data: - 2026_04_29_000001 fixes required_accesses (idempotent, drops empty table or leaves correct schema alone) - 2026_04_29_000002 fixes the rest (permissions, permission_members, permission_usages, roles, role_members, accesses) by adding staging uuid columns, generating UUIDs per row, propagating into FK columns, swapping in place, and rebuilding FK constraints. MySQL-only; SQLite hosts get the correct schema directly from the create migration. Idempotent (no-op on already-uuid schemas). Models / traits - Permission/PermissionMember restored to HasUuids (the schema fix removes the conflict with the bigint id columns) - RoleMember constructor was looking up the wrong config key (role_members instead of role_member) and falling through to a non-pluralised parent::getTable() - HasRoles/HasPermissions now treat UUID strings as ids; previously they were misinterpreted as role/permission names, so passing $role->id to assignRole created a new role keyed by the UUID - extendOrAddRoleByOrigin no longer json_encodes the context array; the RoleMember 'context' cast handles it (it was double-encoding) Reusable infrastructure - MorphAliasRegistry: central alias <-> FQCN map with custom per-class alias and name resolvers. Auto-bound as a singleton in RolesServiceProvider; hosts register their own (alias, FQCN) pairs - HasRequiredAccess gained addRequiredAccessByAlias / removeRequiredAccessByAlias / requiredAccessAdminPayload helpers - RequiredAccess::toAdminArray serializes a link via the registry Test fixtures - Manual DB::table()->insert() pivot rows now pass an explicit id since pivot inserts don't go through HasUuids - All 162 package tests passing Co-Authored-By: Claude Opus 4.7 (1M context) --- ...5_01_01_000001_create_blax_role_tables.php | 30 +- ..._01_01_000002_create_blax_access_table.php | 12 +- ...26_000001_add_source_to_accesses_table.php | 2 +- ..._000001_create_required_accesses_table.php | 10 +- ...001_fix_required_accesses_uuid_columns.php | 77 ++++ ...6_04_29_000002_fix_role_tables_to_uuid.php | 334 ++++++++++++++++++ src/Facades/MorphAliases.php | 25 ++ src/Models/RequiredAccess.php | 29 ++ src/Models/RoleMember.php | 7 +- src/RolesServiceProvider.php | 7 + src/Support/MorphAliasRegistry.php | 188 ++++++++++ src/Traits/HasPermissions.php | 4 +- src/Traits/HasRequiredAccess.php | 59 ++++ src/Traits/HasRoles.php | 49 ++- tests/Unit/HasAccessTest.php | 2 + tests/Unit/HasPermissionsTest.php | 4 + tests/Unit/HasRolesTest.php | 7 + 17 files changed, 806 insertions(+), 40 deletions(-) create mode 100644 database/migrations/2026_04_29_000001_fix_required_accesses_uuid_columns.php create mode 100644 database/migrations/2026_04_29_000002_fix_role_tables_to_uuid.php create mode 100644 src/Facades/MorphAliases.php create mode 100644 src/Support/MorphAliasRegistry.php diff --git a/database/migrations/2025_01_01_000001_create_blax_role_tables.php b/database/migrations/2025_01_01_000001_create_blax_role_tables.php index 910407d..2380734 100644 --- a/database/migrations/2025_01_01_000001_create_blax_role_tables.php +++ b/database/migrations/2025_01_01_000001_create_blax_role_tables.php @@ -19,9 +19,15 @@ return new class extends Migration */ public function up(): void { + // All keys are UUIDs to match the models (HasUuids) and the morph + // columns of host apps that are themselves UUID-keyed. Earlier + // versions of this migration shipped with $table->id() / morphs() + // which produced bigint columns and silently broke every insert + // ("Incorrect integer value: '' for column 'id'"). The + // 2026_04_29 fix-up migration converts existing installs in place. if (! Schema::hasTable(config('roles.table_names.permissions'))) { Schema::create(config('roles.table_names.permissions'), function (Blueprint $table) { - $table->id(); + $table->uuid('id')->primary(); $table->string('slug')->unique(); $table->string('description')->nullable(); $table->timestamps(); @@ -30,9 +36,9 @@ return new class extends Migration if (! Schema::hasTable(config('roles.table_names.permission_member'))) { Schema::create(config('roles.table_names.permission_member'), function (Blueprint $table) { - $table->id(); - $table->foreignId('permission_id')->constrained('permissions')->onDelete('cascade'); - $table->morphs('member'); + $table->uuid('id')->primary(); + $table->foreignUuid('permission_id')->constrained('permissions')->onDelete('cascade'); + $table->uuidMorphs('member'); $table->json('context')->nullable(); $table->timestamp('expires_at')->nullable(); $table->timestamps(); @@ -41,10 +47,10 @@ return new class extends Migration if (! Schema::hasTable(config('roles.table_names.permission_usage'))) { Schema::create(config('roles.table_names.permission_usage'), function (Blueprint $table) { - $table->id(); - $table->foreignId('permission_id')->constrained('permissions')->onDelete('cascade'); + $table->uuid('id')->primary(); + $table->foreignUuid('permission_id')->constrained('permissions')->onDelete('cascade'); $table->float('usage', 8)->default(1); - $table->morphs('user'); + $table->uuidMorphs('user'); $table->json('context')->nullable(); $table->timestamps(); }); @@ -52,8 +58,8 @@ return new class extends Migration if (! Schema::hasTable(config('roles.table_names.roles'))) { Schema::create(config('roles.table_names.roles'), function (Blueprint $table) { - $table->id(); - $table->foreignId('parent_id') + $table->uuid('id')->primary(); + $table->foreignUuid('parent_id') ->nullable() ->constrained('roles') ->onDelete('set null'); @@ -66,9 +72,9 @@ return new class extends Migration if (! Schema::hasTable(config('roles.table_names.role_member'))) { Schema::create(config('roles.table_names.role_member'), function (Blueprint $table) { - $table->id(); - $table->foreignId('role_id')->constrained('roles')->onDelete('cascade'); - $table->morphs('member'); + $table->uuid('id')->primary(); + $table->foreignUuid('role_id')->constrained('roles')->onDelete('cascade'); + $table->uuidMorphs('member'); $table->json('context')->nullable(); $table->timestamp('expires_at')->nullable(); $table->timestamps(); diff --git a/database/migrations/2025_01_01_000002_create_blax_access_table.php b/database/migrations/2025_01_01_000002_create_blax_access_table.php index 15fb4ce..ec4aa46 100644 --- a/database/migrations/2025_01_01_000002_create_blax_access_table.php +++ b/database/migrations/2025_01_01_000002_create_blax_access_table.php @@ -24,10 +24,14 @@ return new class extends Migration } Schema::create($table, function (Blueprint $blueprint) { - $blueprint->id(); - $blueprint->morphs('entity'); // Who has the access (User, Role, Permission) - $blueprint->morphs('accessible'); // What they have access to (Lection, Scenario, etc.) - $blueprint->nullableMorphs('source'); // What conferred this access (Subscription, Order, etc.) + // UUID schema so morph_id columns can store the UUID PKs of host + // models (Users / Roles / Permissions are all HasUuids). Earlier + // versions of this migration used bigint and quietly broke every + // insert with "Incorrect integer value: ''". + $blueprint->uuid('id')->primary(); + $blueprint->uuidMorphs('entity'); // Who has the access (User, Role, Permission) + $blueprint->uuidMorphs('accessible'); // What they have access to (Lection, Scenario, etc.) + $blueprint->nullableUuidMorphs('source'); // What conferred this access (Subscription, Order, etc.) $blueprint->json('context')->nullable(); $blueprint->timestamp('expires_at')->nullable(); $blueprint->timestamps(); diff --git a/database/migrations/2026_04_26_000001_add_source_to_accesses_table.php b/database/migrations/2026_04_26_000001_add_source_to_accesses_table.php index 4ed9b46..71d3ed3 100644 --- a/database/migrations/2026_04_26_000001_add_source_to_accesses_table.php +++ b/database/migrations/2026_04_26_000001_add_source_to_accesses_table.php @@ -41,7 +41,7 @@ return new class extends Migration if (! Schema::hasColumn($table, 'source_id')) { Schema::table($table, function (Blueprint $blueprint) { - $blueprint->nullableMorphs('source'); + $blueprint->nullableUuidMorphs('source'); }); } diff --git a/database/migrations/2026_04_27_000001_create_required_accesses_table.php b/database/migrations/2026_04_27_000001_create_required_accesses_table.php index be71230..95ffca6 100644 --- a/database/migrations/2026_04_27_000001_create_required_accesses_table.php +++ b/database/migrations/2026_04_27_000001_create_required_accesses_table.php @@ -28,9 +28,13 @@ return new class extends Migration } Schema::create($table, function (Blueprint $blueprint) { - $blueprint->id(); - $blueprint->morphs('holder'); // The gated entity (e.g. Lection) - $blueprint->morphs('required'); // The entity whose access unlocks the holder (e.g. Course) + // RequiredAccess uses HasUuids; holder/required ids come from + // application models that also use UUIDs (Blog/Course/Lection, + // Product, Scenario, …). Use uuidMorphs + a uuid PK so the + // morph columns can store the UUIDs without truncation. + $blueprint->uuid('id')->primary(); + $blueprint->uuidMorphs('holder'); // The gated entity (e.g. Lection) + $blueprint->uuidMorphs('required'); // The entity whose access unlocks the holder (e.g. Course) $blueprint->timestamps(); $blueprint->unique( diff --git a/database/migrations/2026_04_29_000001_fix_required_accesses_uuid_columns.php b/database/migrations/2026_04_29_000001_fix_required_accesses_uuid_columns.php new file mode 100644 index 0000000..2fbbd2e --- /dev/null +++ b/database/migrations/2026_04_29_000001_fix_required_accesses_uuid_columns.php @@ -0,0 +1,77 @@ +morphs() (bigint + * holder/required ids) and $table->id() (bigint PK). That doesn't match + * the RequiredAccess model (HasUuids) or the typical host app whose + * gated models also use UUIDs — every insert blew up with + * "Incorrect integer value: '' for column 'holder_id'" + * + * Drop and recreate the table with uuidMorphs + uuid PK. We can drop + * because no row could have ever been inserted under the old schema. + * Hosts that somehow do have rows should run a manual data migration + * before this one. + */ +return new class extends Migration +{ + public function up(): void + { + $table = config('roles.table_names.required_accesses', 'required_accesses'); + + if (! Schema::hasTable($table)) { + return; + } + + // Skip when the schema is already correct — this happens on fresh + // installs that ran the updated create migration, and on hosts who + // hand-fixed the table before this fix-up shipped. Use Laravel's + // Schema::getColumns() so this works on MySQL/PostgreSQL/SQLite + // (INFORMATION_SCHEMA is MySQL-only). + $holderIdInfo = collect(Schema::getColumns($table)) + ->firstWhere('name', 'holder_id'); + $isUuidColumn = $holderIdInfo + && in_array(strtolower((string) ($holderIdInfo['type_name'] ?? '')), ['char', 'varchar', 'uuid'], true); + if ($isUuidColumn) { + return; + } + + // Defensive: refuse to drop a non-empty table. Nobody's been able to + // insert a row under the broken schema, so this should always be 0 + // — but if a host migrated rows in by hand we don't want to nuke them. + $rowCount = DB::table($table)->count(); + if ($rowCount > 0) { + throw new \RuntimeException( + "Refusing to recreate `{$table}`: it contains {$rowCount} rows. " + . 'Migrate them off, drop the table, and re-run the migration manually.' + ); + } + + Schema::drop($table); + + Schema::create($table, function (Blueprint $blueprint) { + $blueprint->uuid('id')->primary(); + $blueprint->uuidMorphs('holder'); + $blueprint->uuidMorphs('required'); + $blueprint->timestamps(); + + $blueprint->unique( + ['holder_type', 'holder_id', 'required_type', 'required_id'], + 'required_access_unique', + ); + $blueprint->index(['required_type', 'required_id'], 'required_access_reverse'); + }); + } + + public function down(): void + { + // No down — there's no good way to restore the broken bigint schema + // and nothing useful was stored in it anyway. + } +}; diff --git a/database/migrations/2026_04_29_000002_fix_role_tables_to_uuid.php b/database/migrations/2026_04_29_000002_fix_role_tables_to_uuid.php new file mode 100644 index 0000000..d3606b1 --- /dev/null +++ b/database/migrations/2026_04_29_000002_fix_role_tables_to_uuid.php @@ -0,0 +1,334 @@ +id()` and `$table->morphs()` + * — bigint everywhere — but the models (Permission / Role / + * RoleMember / PermissionMember / Access / RequiredAccess) all use + * `HasUuids`. So every insert blew up with + * "Incorrect integer value: '' for column 'id'" + * + * On installs that ran the buggy migrations, the tables exist with + * bigint keys. Some hosts have data inside (e.g. roles seeded by app + * code that bypassed the model insert path). This migration converts + * those tables in place: keeps the data, swaps PK + FK columns to + * CHAR(36), and rebuilds all foreign-key constraints. + * + * Idempotent. Each phase checks whether the conversion has already + * been applied (by reading the column type from + * INFORMATION_SCHEMA) and skips when the schema is already correct, + * so re-running is safe and fresh installs (whose newer create + * migration already produces UUIDs) take a fast no-op path. + * + * Caveats: + * - DDL is auto-commit on MySQL; failures mid-run leave a partial + * conversion. Take a backup before deploying. + * - Morph id columns become CHAR(36). Hosts whose entities use bigint + * PKs (e.g. User on this app) have their ints stringified; Laravel's + * morph relations compare loosely, so this is transparent. + */ +return new class extends Migration +{ + public function up(): void + { + // The fix-up only runs on MySQL — that's the driver where the + // broken bigint schema was actually created on production hosts. + // On SQLite (used by package tests and some hosts in dev) the + // updated create migration produces the correct UUID schema + // directly, and the multi-table UPDATE/ALTER syntax we use here + // (MySQL-specific) isn't supported anyway. + if (DB::connection()->getDriverName() !== 'mysql') { + return; + } + + $tables = [ + 'permissions' => config('roles.table_names.permissions', 'permissions'), + 'permission_members' => config('roles.table_names.permission_member', 'permission_members'), + 'permission_usages' => config('roles.table_names.permission_usage', 'permission_usages'), + 'roles' => config('roles.table_names.roles', 'roles'), + 'role_members' => config('roles.table_names.role_member', 'role_members'), + 'accesses' => config('roles.table_names.accesses', 'accesses'), + ]; + + // Phase 1 — parent tables (permissions, roles): convert PK from + // bigint → uuid AND propagate the new ids into every dependent + // table's FK column in the same step, so dependents are valid + // again before we drop their old bigint FK columns in Phase 2. + $this->convertParent($tables['permissions'], [ + $tables['permission_members'] => ['permission_id', 'permission_members_permission_id_foreign', 'cascade', false], + $tables['permission_usages'] => ['permission_id', 'permission_usages_permission_id_foreign', 'cascade', false], + ]); + + // Roles has a self-FK on parent_id, so the "dependent" includes the + // table itself. The fourth tuple element flags a self-FK column. + $this->convertParent($tables['roles'], [ + $tables['role_members'] => ['role_id', 'role_members_role_id_foreign', 'cascade', false], + $tables['roles'] => ['parent_id', 'roles_parent_id_foreign', 'set null', true], + ]); + + // Phase 2 — for each child table, swap its own bigint id (PK) to + // uuid. No FKs reference these so we can convert independently. + foreach (['permission_members', 'permission_usages', 'role_members'] as $key) { + $this->convertOwnIdColumn($tables[$key]); + } + + // Phase 3 — accesses table: own id, plus polymorphic morph id + // columns (entity_id, source_id). accessible_id is already + // varchar(36) on most installs. + $this->convertOwnIdColumn($tables['accesses']); + $this->convertMorphIdColumn($tables['accesses'], 'entity_id'); + $this->convertMorphIdColumn($tables['accesses'], 'source_id'); + $this->convertMorphIdColumn($tables['accesses'], 'accessible_id'); + } + + public function down(): void + { + // No-op. There's no safe way to recover the bigint schema once + // UUIDs are assigned (we'd be inventing fake auto-increment ids + // and hoping references match). Hosts who need to roll back + // should restore from backup. + } + + /** + * Convert $parent's id from bigint to uuid, propagating the new id + * into every dependent FK column listed in $children. + * + * @param array $children + * keyed by child table name; tuple = [fkColumn, fkConstraintName, onDelete, isSelfFk] + */ + private function convertParent(string $parent, array $children): void + { + if (! Schema::hasTable($parent)) { + return; + } + + // Already uuid? Skip the whole pass. + if ($this->columnIsUuid($parent, 'id')) { + return; + } + + // 1. Add the staging column for the new uuid id. + if (! Schema::hasColumn($parent, '_uuid_id')) { + DB::statement("ALTER TABLE `{$parent}` ADD COLUMN `_uuid_id` CHAR(36) NULL"); + } + + // 2. Populate _uuid_id row by row. Cheap PHP loop is fine — the + // tables this migration targets are small (admin role/permission + // catalogs, not user-scale). + $rows = DB::table($parent)->whereNull('_uuid_id')->get(['id']); + foreach ($rows as $row) { + DB::table($parent)->where('id', $row->id)->update(['_uuid_id' => (string) Str::uuid()]); + } + + // 3. For each dependent: drop its FK, add a staging uuid FK column, + // populate via JOIN on the parent's old/new ids. + foreach ($children as $child => [$fkCol, $fkName, , $isSelfFk]) { + $this->dropForeignKeyIfExists($child, $fkName); + + $stagingCol = '_uuid_' . $fkCol; + if (! Schema::hasColumn($child, $stagingCol)) { + DB::statement("ALTER TABLE `{$child}` ADD COLUMN `{$stagingCol}` CHAR(36) NULL"); + } + + // The self-FK case: in roles, parent_id can be NULL. Use a left + // join so NULLs stay NULL in the staging column. + if ($isSelfFk) { + DB::statement( + "UPDATE `{$child}` c " + . "LEFT JOIN `{$parent}` p ON c.`{$fkCol}` = p.`id` " + . "SET c.`{$stagingCol}` = p.`_uuid_id` " + . "WHERE c.`{$fkCol}` IS NOT NULL AND c.`{$stagingCol}` IS NULL" + ); + } else { + DB::statement( + "UPDATE `{$child}` c " + . "JOIN `{$parent}` p ON c.`{$fkCol}` = p.`id` " + . "SET c.`{$stagingCol}` = p.`_uuid_id` " + . "WHERE c.`{$stagingCol}` IS NULL" + ); + } + } + + // 4. Swap parent's id. MySQL refuses to drop the primary key + // while the column is auto_increment, so first strip the + // auto_increment attribute, then drop. We resolve the underlying + // column type so this works for both BIGINT and INT etc. + $this->dropAutoIncrementId($parent); + DB::statement("ALTER TABLE `{$parent}` DROP PRIMARY KEY"); + DB::statement("ALTER TABLE `{$parent}` DROP COLUMN `id`"); + DB::statement("ALTER TABLE `{$parent}` CHANGE `_uuid_id` `id` CHAR(36) NOT NULL"); + DB::statement("ALTER TABLE `{$parent}` ADD PRIMARY KEY (`id`)"); + + // 5. Swap each child's FK column and re-create the FK constraint. + foreach ($children as $child => [$fkCol, $fkName, $onDelete, $isSelfFk]) { + $stagingCol = '_uuid_' . $fkCol; + + DB::statement("ALTER TABLE `{$child}` DROP COLUMN `{$fkCol}`"); + + $nullable = $isSelfFk ? 'NULL' : 'NOT NULL'; + DB::statement("ALTER TABLE `{$child}` CHANGE `{$stagingCol}` `{$fkCol}` CHAR(36) {$nullable}"); + + $onDeleteSql = strtoupper($onDelete) === 'SET NULL' ? 'SET NULL' : 'CASCADE'; + DB::statement( + "ALTER TABLE `{$child}` " + . "ADD CONSTRAINT `{$fkName}` " + . "FOREIGN KEY (`{$fkCol}`) REFERENCES `{$parent}`(`id`) " + . "ON DELETE {$onDeleteSql}" + ); + } + } + + /** + * Convert $table's own id column from bigint auto-increment to uuid. + * Used for tables where nothing references the id (or the references + * have already been converted in a prior phase). + */ + private function convertOwnIdColumn(string $table): void + { + if (! Schema::hasTable($table)) { + return; + } + if ($this->columnIsUuid($table, 'id')) { + return; + } + + if (! Schema::hasColumn($table, '_uuid_id')) { + DB::statement("ALTER TABLE `{$table}` ADD COLUMN `_uuid_id` CHAR(36) NULL"); + } + + $rows = DB::table($table)->whereNull('_uuid_id')->get(['id']); + foreach ($rows as $row) { + DB::table($table)->where('id', $row->id)->update(['_uuid_id' => (string) Str::uuid()]); + } + + $this->dropAutoIncrementId($table); + DB::statement("ALTER TABLE `{$table}` DROP PRIMARY KEY"); + DB::statement("ALTER TABLE `{$table}` DROP COLUMN `id`"); + DB::statement("ALTER TABLE `{$table}` CHANGE `_uuid_id` `id` CHAR(36) NOT NULL"); + DB::statement("ALTER TABLE `{$table}` ADD PRIMARY KEY (`id`)"); + } + + /** + * Strip AUTO_INCREMENT from $table.id by re-declaring the column with + * its current SQL type but no auto-increment. MySQL otherwise refuses + * "DROP PRIMARY KEY" on an auto-increment column. No-op when the + * column is already non-auto-increment. + */ + private function dropAutoIncrementId(string $table): void + { + // Only relevant on MySQL — SQLite handles auto-increment differently + // and doesn't need (or support) MODIFY COLUMN to strip it. + if (DB::connection()->getDriverName() !== 'mysql') { + return; + } + $info = $this->columnInfo($table, 'id'); + if (! $info) { + return; + } + // type_name on MySQL gives us "bigint" / "int". Combine with the + // unsigned/signed marker from the full type string so we recreate + // the same column with auto_increment stripped. + $isUnsigned = str_contains(strtolower($info['type']), 'unsigned'); + $type = $info['type_name'] . ($isUnsigned ? ' unsigned' : ''); + DB::statement("ALTER TABLE `{$table}` MODIFY `id` {$type} NOT NULL"); + } + + /** + * Widen a polymorphic morph_id column to CHAR(36) so it can hold + * either a UUID (host models that use HasUuids) or a stringified + * bigint (host models that use auto-increment, e.g. User on apps + * where users haven't been migrated yet). Existing values are + * cast to string in place. + */ + private function convertMorphIdColumn(string $table, string $column): void + { + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) { + return; + } + if ($this->columnIsUuid($table, $column)) { + return; + } + + // Whether the column allows NULL — we want to preserve that. + $nullable = $this->columnIsNullable($table, $column); + + $stagingCol = '_uuid_' . $column; + if (! Schema::hasColumn($table, $stagingCol)) { + DB::statement("ALTER TABLE `{$table}` ADD COLUMN `{$stagingCol}` CHAR(36) NULL"); + } + // Stringify the existing bigint value into the staging column. + DB::statement( + "UPDATE `{$table}` SET `{$stagingCol}` = CAST(`{$column}` AS CHAR) " + . "WHERE `{$column}` IS NOT NULL AND `{$stagingCol}` IS NULL" + ); + + DB::statement("ALTER TABLE `{$table}` DROP COLUMN `{$column}`"); + + $nullSql = $nullable ? 'NULL' : 'NOT NULL'; + DB::statement("ALTER TABLE `{$table}` CHANGE `{$stagingCol}` `{$column}` CHAR(36) {$nullSql}"); + } + + /** + * Driver-agnostic column inspection. Schema::getColumns() is the + * Laravel-supported API that works on MySQL, PostgreSQL, and SQLite — + * unlike INFORMATION_SCHEMA, which exists on neither SQLite nor in + * the form expected on every MySQL build. + * + * @return array{type_name: string, type: string, nullable: bool}|null + */ + private function columnInfo(string $table, string $column): ?array + { + if (! Schema::hasColumn($table, $column)) { + return null; + } + foreach (Schema::getColumns($table) as $c) { + if (($c['name'] ?? null) === $column) { + return [ + 'type_name' => strtolower((string) ($c['type_name'] ?? '')), + 'type' => (string) ($c['type'] ?? ''), + 'nullable' => (bool) ($c['nullable'] ?? false), + ]; + } + } + return null; + } + + private function columnIsUuid(string $table, string $column): bool + { + $info = $this->columnInfo($table, $column); + return $info && in_array($info['type_name'], ['char', 'varchar', 'uuid'], true); + } + + private function columnIsNullable(string $table, string $column): bool + { + $info = $this->columnInfo($table, $column); + return $info && $info['nullable']; + } + + private function dropForeignKeyIfExists(string $table, string $fkName): void + { + // SQLite doesn't support DROP FOREIGN KEY at all and instead + // requires a full table rebuild. The migration is designed for + // MySQL bigint→UUID conversion in production; on SQLite the + // schema is created fresh as UUID via the create migration so + // there's nothing to drop. + if (DB::connection()->getDriverName() === 'sqlite') { + return; + } + try { + DB::statement("ALTER TABLE `{$table}` DROP FOREIGN KEY `{$fkName}`"); + } catch (\Throwable $e) { + // FK doesn't exist — fine. + } + } +}; diff --git a/src/Facades/MorphAliases.php b/src/Facades/MorphAliases.php new file mode 100644 index 0000000..4547c7b --- /dev/null +++ b/src/Facades/MorphAliases.php @@ -0,0 +1,25 @@ + all() + * + * @see MorphAliasRegistry + */ +class MorphAliases extends Facade +{ + protected static function getFacadeAccessor(): string + { + return MorphAliasRegistry::class; + } +} diff --git a/src/Models/RequiredAccess.php b/src/Models/RequiredAccess.php index 8f66468..0129a9d 100644 --- a/src/Models/RequiredAccess.php +++ b/src/Models/RequiredAccess.php @@ -47,4 +47,33 @@ class RequiredAccess extends Model { return $this->morphTo(); } + + /** + * Render this link as an admin-friendly payload, using the + * MorphAliasRegistry for the type alias and human-readable name. + * + * Returns null for orphaned rows (target deleted) so callers can + * `->filter()` them out without explicit guards. Hosts that need + * extra fields can compose this with their own merge: + * + * $link->toAdminArray() + ['custom' => $link->required->custom] + */ + public function toAdminArray(): ?array + { + $target = $this->required; + if (! $target) { + return null; + } + + /** @var \Blax\Roles\Support\MorphAliasRegistry $registry */ + $registry = app(\Blax\Roles\Support\MorphAliasRegistry::class); + + return [ + 'id' => $this->id, + 'target_type' => $registry->aliasFor($this->required_type, $target), + 'target_id' => (string) $this->required_id, + 'name' => $registry->nameFor($target), + 'is_published' => ($target->published_at ?? null) !== null, + ]; + } } diff --git a/src/Models/RoleMember.php b/src/Models/RoleMember.php index 355d8cc..4187a7d 100644 --- a/src/Models/RoleMember.php +++ b/src/Models/RoleMember.php @@ -30,7 +30,12 @@ class RoleMember extends MorphPivot { parent::__construct($attributes); - $this->table = config('roles.table_names.role_members') ?: parent::getTable(); + // Config uses the singular key 'role_member' (mapped to 'role_members' + // in the default config). Looking up the plural key returned null and + // fell through to parent::getTable() — which on a MorphPivot returns + // a non-pluralised "role_member", pointing direct queries at a table + // that doesn't exist. + $this->table = config('roles.table_names.role_member') ?: parent::getTable(); } public function role() diff --git a/src/RolesServiceProvider.php b/src/RolesServiceProvider.php index c55149c..b859db4 100644 --- a/src/RolesServiceProvider.php +++ b/src/RolesServiceProvider.php @@ -15,6 +15,13 @@ class RolesServiceProvider extends \Illuminate\Support\ServiceProvider __DIR__ . '/../config/roles.php', 'roles' ); + + // Bind the MorphAliasRegistry as a singleton with no aliases registered + // by default. Hosts add their own (alias, FQCN) pairs in their service + // provider via app(MorphAliasRegistry::class)->register(...). Hosts + // that override the binding entirely (e.g. to swap in a stricter + // implementation) still work — Laravel honours the host's binding. + $this->app->singleton(\Blax\Roles\Support\MorphAliasRegistry::class); } /** diff --git a/src/Support/MorphAliasRegistry.php b/src/Support/MorphAliasRegistry.php new file mode 100644 index 0000000..ea04605 --- /dev/null +++ b/src/Support/MorphAliasRegistry.php @@ -0,0 +1,188 @@ + fully-qualified class name. + * + * @var array + */ + protected array $map = []; + + /** + * Custom alias resolvers for cases where one class maps to multiple + * aliases depending on the instance (e.g. App\Models\Blog → "course" + * or "lection" depending on the URL prefix). Keyed by class name; the + * resolver receives the model and returns an alias string or null + * (null = fall through to the static map / class basename). + * + * @var array + */ + protected array $aliasResolvers = []; + + /** + * Per-class human-name resolvers. The resolver receives the model + * and returns the display name. If null is returned, the registry + * falls through to its default chain (getLocalized('title'/'name'), + * title, name, slug, …). + * + * @var array + */ + protected array $nameResolvers = []; + + /** + * Register an alias → class mapping, optionally with a custom name + * resolver for that class. The name resolver is the place to handle + * compound names (e.g. ProductPrice → "Product Name — Tier"). + * + * Idempotent: re-registering the same alias overrides the previous + * mapping, which makes test setup convenient. + * + * @param callable(Model): ?string|null $nameResolver + */ + public function register(string $alias, string $class, ?callable $nameResolver = null): void + { + $this->map[$alias] = $class; + if ($nameResolver) { + $this->nameResolvers[$class] = $nameResolver; + } + } + + /** + * Register a custom alias resolver for cases where one class maps to + * multiple aliases per-instance. The resolver should return null to + * let the static map take over for instances it doesn't recognise. + * + * @param callable(Model): ?string $resolver + */ + public function registerAliasResolver(string $class, callable $resolver): void + { + $this->aliasResolvers[$class] = $resolver; + } + + /** + * Resolve an alias (or already-FQCN string) to a class name, or null + * if neither matches. + */ + public function resolveClass(string $alias): ?string + { + if (isset($this->map[$alias])) { + return $this->map[$alias]; + } + + // Backwards compatibility: callers sometimes pass a FQCN directly. + if (class_exists($alias)) { + return $alias; + } + + return null; + } + + /** + * Reverse-look-up: given a stored morph class (and optionally the + * actual instance), return a short alias for UI badges. Falls back + * to a lower-cased class basename when nothing is registered, so + * the result is always a non-empty string. + */ + public function aliasFor(string $morphClass, ?Model $instance = null): string + { + // Per-instance custom resolver wins (e.g. Blog → course/lection). + if ($instance) { + foreach ($this->aliasResolvers as $class => $resolver) { + if ($morphClass === $class || is_subclass_of($morphClass, $class)) { + $alias = $resolver($instance); + if ($alias) { + return $alias; + } + } + } + } + + // Static reverse lookup (exact match first, then parents). + $alias = array_search($morphClass, $this->map, true); + if ($alias !== false) { + return $alias; + } + foreach ($this->map as $a => $c) { + if (is_subclass_of($morphClass, $c)) { + return $a; + } + } + + return strtolower(class_basename($morphClass)); + } + + /** + * Best-effort human-readable name for any model the registry knows. + * Hosts can supply per-class resolvers via register()/registerNameResolver + * for compound names; otherwise we walk the usual attribute chain. + */ + public function nameFor(Model $model): string + { + // Walk class hierarchy so a resolver registered for the parent + // (e.g. ProductPrice) still applies to subclasses. + foreach ($this->nameResolvers as $class => $resolver) { + if ($model instanceof $class) { + $name = $resolver($model); + if ($name !== null && $name !== '') { + return $name; + } + } + } + + // Default chain: localised title/name → bare title/name → slug. + if (method_exists($model, 'getLocalized')) { + $localized = $model->getLocalized('title') ?? $model->getLocalized('name'); + if ($localized) { + return $localized; + } + } + + return $model->title + ?? $model->name + ?? $model->slug + ?? 'Untitled'; + } + + /** + * Register a standalone name resolver without (re-)registering the alias. + * + * @param callable(Model): ?string $resolver + */ + public function registerNameResolver(string $class, callable $resolver): void + { + $this->nameResolvers[$class] = $resolver; + } + + /** + * @return array alias => class + */ + public function all(): array + { + return $this->map; + } +} diff --git a/src/Traits/HasPermissions.php b/src/Traits/HasPermissions.php index 54236c9..1c2cb3e 100644 --- a/src/Traits/HasPermissions.php +++ b/src/Traits/HasPermissions.php @@ -113,7 +113,7 @@ trait HasPermissions { $permission_class = config('roles.models.permission'); - if (is_numeric($permission)) { + if (is_numeric($permission) || (is_string($permission) && \Illuminate\Support\Str::isUuid($permission))) { $permission = $permission_class::find($permission); } elseif (is_string($permission)) { $permission = $permission_class::firstOrCreate([ @@ -134,7 +134,7 @@ trait HasPermissions { $permission_class = config('roles.models.permission'); - if (is_numeric($permission)) { + if (is_numeric($permission) || (is_string($permission) && \Illuminate\Support\Str::isUuid($permission))) { $permission = $permission_class::find($permission); } elseif (is_string($permission)) { $permission = $permission_class::where('slug', $permission)->first(); diff --git a/src/Traits/HasRequiredAccess.php b/src/Traits/HasRequiredAccess.php index 872a401..a577422 100644 --- a/src/Traits/HasRequiredAccess.php +++ b/src/Traits/HasRequiredAccess.php @@ -68,6 +68,65 @@ trait HasRequiredAccess ]); } + /** + * Add a required-access target by short alias + id, looking the + * target up via the package's MorphAliasRegistry. + * + * Returns null when either the alias is unknown or the target row + * doesn't exist — callers (typically WS controllers) translate that + * into a 4xx response. This keeps every host's admin controllers + * out of the business of resolving alias maps themselves. + */ + public function addRequiredAccessByAlias(string $alias, string|int $id): ?Model + { + $class = app(\Blax\Roles\Support\MorphAliasRegistry::class)->resolveClass($alias); + if (! $class || ! class_exists($class)) { + return null; + } + + $target = $class::find($id); + if (! $target) { + return null; + } + + return $this->addRequiredAccess($target); + } + + /** + * Remove a required-access target by short alias + id. Returns the + * delete count (0 if the alias was unknown or the target missing). + */ + public function removeRequiredAccessByAlias(string $alias, string|int $id): int + { + $class = app(\Blax\Roles\Support\MorphAliasRegistry::class)->resolveClass($alias); + if (! $class || ! class_exists($class)) { + return 0; + } + + $target = $class::find($id); + if (! $target) { + return 0; + } + + return $this->removeRequiredAccess($target); + } + + /** + * Convenience: list this holder's required-access links rendered + * for admin payloads (uses RequiredAccess::toAdminArray which goes + * through the MorphAliasRegistry for type aliases and names). + * + * @return array + */ + public function requiredAccessAdminPayload(): array + { + return $this->requiredAccessLinks()->with('required')->get() + ->map(fn($link) => $link->toAdminArray()) + ->filter() + ->values() + ->all(); + } + /** * Remove a required-access target. * diff --git a/src/Traits/HasRoles.php b/src/Traits/HasRoles.php index f5af7ff..24af7f8 100644 --- a/src/Traits/HasRoles.php +++ b/src/Traits/HasRoles.php @@ -3,11 +3,23 @@ namespace Blax\Roles\Traits; use Blax\Roles\Models\Role; +use Illuminate\Support\Str; trait HasRoles { use HasPermissions; + /** + * A "role identifier" is anything that can be passed in lieu of a Role + * model: a numeric primary key (legacy), a UUID primary key, or a + * Role instance. Anything else (a non-numeric, non-UUID string) is + * treated as a *name* by the higher-level methods. + */ + private static function isRoleIdString(mixed $value): bool + { + return is_numeric($value) || (is_string($value) && Str::isUuid($value)); + } + /** * Get all roles for the user. * @@ -62,13 +74,13 @@ trait HasRoles */ public function assignRole(string|Role $role, int $max_times = 1) { - if (is_string($role) && !is_numeric($role)) { + if (self::isRoleIdString($role)) { + $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); + } elseif (is_string($role)) { $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::firstOrCreate([ 'name' => $role, 'slug' => str()->slug($role) ]); - } elseif (is_numeric($role)) { - $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); } if ($max_times >= 0) { @@ -96,10 +108,10 @@ trait HasRoles */ public function removeRole(string|Role $role) { - if (is_string($role) && !is_numeric($role)) { - $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::where('slug', $role)->first(); - } elseif (is_numeric($role)) { + if (self::isRoleIdString($role)) { $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); + } elseif (is_string($role)) { + $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::where('slug', $role)->first(); } elseif (!$role instanceof Role) { throw new \InvalidArgumentException('Role must be a string, numeric ID, or an instance of Role.'); } @@ -122,14 +134,14 @@ trait HasRoles { $roleIds = []; foreach ($roles as $role) { - if (is_string($role) && !is_numeric($role)) { + if (self::isRoleIdString($role)) { + $roleModel = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); + } elseif (is_string($role)) { $roleModel = config('roles.models.role', \Blax\Roles\Models\Role::class)::firstOrCreate([ 'name' => $role, ], [ 'slug' => str()->slug($role) ]); - } elseif (is_numeric($role)) { - $roleModel = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); } elseif ($role instanceof Role) { $roleModel = $role; } elseif (is_object($role) && isset($role->id)) { @@ -167,14 +179,14 @@ trait HasRoles } // Resolve role - if (is_string($role) && !is_numeric($role)) { + if (self::isRoleIdString($role)) { + $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); + } elseif (is_string($role)) { $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::firstOrCreate([ 'name' => $role, ], [ 'slug' => str()->slug($role) ]); - } elseif (is_numeric($role)) { - $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); } elseif (!$role instanceof Role) { throw new \InvalidArgumentException('Role must be a string, numeric ID, or an instance of Role.'); } @@ -227,14 +239,14 @@ trait HasRoles } // Resolve role - if (is_string($role) && !is_numeric($role)) { + if (self::isRoleIdString($role)) { + $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); + } elseif (is_string($role)) { $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::firstOrCreate([ 'name' => $role, ], [ 'slug' => str()->slug($role) ]); - } elseif (is_numeric($role)) { - $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); } elseif (!$role instanceof Role) { throw new \InvalidArgumentException('Role must be a string, numeric ID, or an instance of Role.'); } @@ -255,12 +267,15 @@ trait HasRoles if ($existing) { $existing->extendByHours($hours, $forceExpiry); } else { + // Pass the context as an array — the RoleMember pivot has a + // 'context' => 'array' cast that JSON-encodes it on save. + // json_encode()-ing it here would double-encode the value. $this->roles()->attach($role->id, [ 'expires_at' => now()->addHours($hours), - 'context' => json_encode([ + 'context' => [ 'origin_name' => $originName, 'origin_value' => $originValue, - ]), + ], ]); } diff --git a/tests/Unit/HasAccessTest.php b/tests/Unit/HasAccessTest.php index fd0da7c..7ed1e4f 100644 --- a/tests/Unit/HasAccessTest.php +++ b/tests/Unit/HasAccessTest.php @@ -9,6 +9,7 @@ use Blax\Roles\RolesServiceProvider; use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Orchestra\Testbench\TestCase; use Workbench\App\Models\Article; use Workbench\App\Models\User; @@ -234,6 +235,7 @@ class HasAccessTest extends TestCase 'id' => (string) \Illuminate\Support\Str::uuid(), 'role_id' => $role->id, 'member_id' => $user->id, + 'id' => (string) Str::uuid(), 'member_type' => $user->getMorphClass(), 'expires_at' => now()->subDay(), 'created_at' => now(), diff --git a/tests/Unit/HasPermissionsTest.php b/tests/Unit/HasPermissionsTest.php index ea91da1..1354475 100644 --- a/tests/Unit/HasPermissionsTest.php +++ b/tests/Unit/HasPermissionsTest.php @@ -7,6 +7,7 @@ use Blax\Roles\Models\Role; use Blax\Roles\RolesServiceProvider; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Orchestra\Testbench\TestCase; use Workbench\App\Models\User; @@ -180,6 +181,7 @@ class HasPermissionsTest extends TestCase DB::table(config('roles.table_names.role_member'))->insert([ 'role_id' => $role->id, 'member_id' => $user->id, + 'id' => (string) Str::uuid(), 'member_type' => $user->getMorphClass(), 'expires_at' => now()->subDay(), 'created_at' => now(), @@ -201,6 +203,7 @@ class HasPermissionsTest extends TestCase DB::table(config('roles.table_names.role_member'))->insert([ 'role_id' => $role->id, 'member_id' => $user->id, + 'id' => (string) Str::uuid(), 'member_type' => $user->getMorphClass(), 'expires_at' => now()->addDays(7), 'created_at' => now(), @@ -232,6 +235,7 @@ class HasPermissionsTest extends TestCase DB::table(config('roles.table_names.permission_member'))->insert([ 'permission_id' => $perm->id, 'member_id' => $role->id, + 'id' => (string) Str::uuid(), 'member_type' => $role->getMorphClass(), 'expires_at' => now()->subHour(), 'created_at' => now(), diff --git a/tests/Unit/HasRolesTest.php b/tests/Unit/HasRolesTest.php index 3180a91..337ef4f 100644 --- a/tests/Unit/HasRolesTest.php +++ b/tests/Unit/HasRolesTest.php @@ -7,6 +7,7 @@ use Blax\Roles\Models\Role; use Blax\Roles\RolesServiceProvider; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Orchestra\Testbench\TestCase; use Workbench\App\Models\User; @@ -328,6 +329,7 @@ class HasRolesTest extends TestCase DB::table(config('roles.table_names.role_member'))->insert([ 'role_id' => $role->id, 'member_id' => $user->id, + 'id' => (string) Str::uuid(), 'member_type' => $user->getMorphClass(), 'expires_at' => now()->subDay(), 'created_at' => now(), @@ -346,6 +348,7 @@ class HasRolesTest extends TestCase DB::table(config('roles.table_names.role_member'))->insert([ 'role_id' => $role->id, 'member_id' => $user->id, + 'id' => (string) Str::uuid(), 'member_type' => $user->getMorphClass(), 'expires_at' => now()->addWeek(), 'created_at' => now(), @@ -383,6 +386,7 @@ class HasRolesTest extends TestCase DB::table(config('roles.table_names.role_member'))->insert([ 'role_id' => $role->id, 'member_id' => $user->id, + 'id' => (string) Str::uuid(), 'member_type' => $user->getMorphClass(), 'expires_at' => now()->addHours(24), 'created_at' => now(), @@ -412,6 +416,7 @@ class HasRolesTest extends TestCase DB::table(config('roles.table_names.role_member'))->insert([ 'role_id' => $role->id, 'member_id' => $user->id, + 'id' => (string) Str::uuid(), 'member_type' => $user->getMorphClass(), 'expires_at' => null, 'created_at' => now(), @@ -572,6 +577,7 @@ class HasRolesTest extends TestCase DB::table(config('roles.table_names.role_member'))->insert([ 'role_id' => $role->id, 'member_id' => $user->id, + 'id' => (string) Str::uuid(), 'member_type' => $user->getMorphClass(), 'expires_at' => now()->subDay(), 'context' => json_encode(['origin_name' => 'Monthly Sub', 'origin_value' => 'ProductPrice:sub-monthly']), @@ -618,6 +624,7 @@ class HasRolesTest extends TestCase DB::table(config('roles.table_names.role_member'))->insert([ 'role_id' => $role->id, 'member_id' => $user->id, + 'id' => (string) Str::uuid(), 'member_type' => $user->getMorphClass(), 'expires_at' => null, 'context' => json_encode(['origin_name' => 'Sub', 'origin_value' => 'ProductPrice:sub']),