Skip to content

Commit d2ba465

Browse files
authored
feat: sync and seed method (#33)
Add new programmatic sync() method to the Mandate service that provides the same functionality as the mandate:sync command, allowing developers to sync code-first definitions and seed role assignments programmatically. The PR also introduces a SyncResult class to encapsulate the sync operation results.
1 parent d38e47c commit d2ba465

File tree

4 files changed

+737
-0
lines changed

4 files changed

+737
-0
lines changed

src/Facades/Mandate.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use OffloadProject\Mandate\Contracts\Role as RoleContract;
1515
use OffloadProject\Mandate\Mandate as MandateService;
1616
use OffloadProject\Mandate\MandateRegistrar;
17+
use OffloadProject\Mandate\SyncResult;
1718

1819
/**
1920
* Facade for the Mandate service.
@@ -70,6 +71,9 @@
7071
* @method static CapabilityContract findOrCreateCapability(string $name, ?string $guard = null)
7172
* @method static Collection<int, CapabilityContract> getAllCapabilities(?string $guard = null)
7273
*
74+
* Sync (programmatic equivalent of mandate:sync command):
75+
* @method static SyncResult sync(bool $permissions = false, bool $roles = false, bool $capabilities = false, bool $seed = false, ?string $guard = null)
76+
*
7377
* Utility:
7478
* @method static bool clearCache()
7579
* @method static MandateRegistrar getRegistrar()

src/Mandate.php

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@
77
use Illuminate\Contracts\Auth\Authenticatable;
88
use Illuminate\Database\Eloquent\Model;
99
use Illuminate\Support\Collection;
10+
use OffloadProject\Mandate\CodeFirst\DefinitionCache;
11+
use OffloadProject\Mandate\CodeFirst\DefinitionDiscoverer;
1012
use OffloadProject\Mandate\Concerns\HasRoles;
1113
use OffloadProject\Mandate\Contracts\Capability as CapabilityContract;
1214
use OffloadProject\Mandate\Contracts\FeatureAccessHandler;
1315
use OffloadProject\Mandate\Contracts\Permission as PermissionContract;
1416
use OffloadProject\Mandate\Contracts\Role as RoleContract;
17+
use OffloadProject\Mandate\Events\CapabilitiesSynced;
18+
use OffloadProject\Mandate\Events\MandateSynced;
19+
use OffloadProject\Mandate\Events\PermissionsSynced;
20+
use OffloadProject\Mandate\Events\RolesSynced;
1521
use OffloadProject\Mandate\Models\Capability;
1622
use OffloadProject\Mandate\Models\Permission;
1723
use OffloadProject\Mandate\Models\Role;
@@ -649,6 +655,343 @@ public function getAuthorizationData(?Authenticatable $subject = null, ?Model $c
649655
return $data;
650656
}
651657

658+
/**
659+
* Sync code-first definitions to the database and optionally seed assignments.
660+
*
661+
* This method programmatically performs the same operations as the `mandate:sync` command.
662+
*
663+
* @param bool $permissions Sync only permissions (if true without roles/capabilities, only permissions are synced)
664+
* @param bool $roles Sync only roles (if true without permissions/capabilities, only roles are synced)
665+
* @param bool $capabilities Sync only capabilities (if true without permissions/roles, only capabilities are synced)
666+
* @param bool $seed Seed role-permission and role-capability assignments from config
667+
* @param string|null $guard Sync for a specific guard only
668+
*
669+
* @throws RuntimeException If code-first mode is not enabled (unless using seed-only mode)
670+
*/
671+
public function sync(
672+
bool $permissions = false,
673+
bool $roles = false,
674+
bool $capabilities = false,
675+
bool $seed = false,
676+
?string $guard = null,
677+
): SyncResult {
678+
$codeFirstEnabled = (bool) config('mandate.code_first.enabled', false);
679+
$seedOnly = $seed && ! $permissions && ! $roles && ! $capabilities;
680+
681+
// Allow seed to work without code-first enabled
682+
if (! $codeFirstEnabled && ! $seedOnly) {
683+
throw new RuntimeException('Code-first mode is not enabled. Set mandate.code_first.enabled to true.');
684+
}
685+
686+
$permissionsCreated = 0;
687+
$permissionsUpdated = 0;
688+
$rolesCreated = 0;
689+
$rolesUpdated = 0;
690+
$capabilitiesCreated = 0;
691+
$capabilitiesUpdated = 0;
692+
693+
$syncAll = ! $permissions && ! $roles && ! $capabilities && ! $seedOnly;
694+
695+
// Determine what to sync (skip if seed-only mode)
696+
$syncPermissions = $codeFirstEnabled && ($syncAll || $permissions);
697+
$syncRoles = $codeFirstEnabled && ($syncAll || $roles);
698+
$syncCapabilities = $codeFirstEnabled && ($syncAll || $capabilities) && $this->capabilitiesEnabled();
699+
700+
// Only instantiate code-first dependencies when actually needed
701+
if ($syncPermissions || $syncRoles || $syncCapabilities) {
702+
$discoverer = app(DefinitionDiscoverer::class);
703+
$cache = app(DefinitionCache::class);
704+
705+
// Sync permissions
706+
if ($syncPermissions) {
707+
$result = $this->syncDefinitionsToDatabase($discoverer, 'permissions', $guard);
708+
$permissionsCreated = $result['created'];
709+
$permissionsUpdated = $result['updated'];
710+
}
711+
712+
// Sync roles
713+
if ($syncRoles) {
714+
$result = $this->syncDefinitionsToDatabase($discoverer, 'roles', $guard);
715+
$rolesCreated = $result['created'];
716+
$rolesUpdated = $result['updated'];
717+
}
718+
719+
// Sync capabilities
720+
if ($syncCapabilities) {
721+
$result = $this->syncDefinitionsToDatabase($discoverer, 'capabilities', $guard);
722+
$capabilitiesCreated = $result['created'];
723+
$capabilitiesUpdated = $result['updated'];
724+
}
725+
726+
// Clear definition cache
727+
$cache->forget();
728+
}
729+
730+
// Seed assignments if requested
731+
$assignmentsSeeded = false;
732+
if ($seed) {
733+
$this->seedAssignmentsFromConfig($guard);
734+
$assignmentsSeeded = true;
735+
}
736+
737+
// Clear permission cache (always needed after any sync/seed operation)
738+
$this->registrar->forgetCachedPermissions();
739+
740+
// Dispatch events only when actual sync operations were performed
741+
$anySyncPerformed = $syncPermissions || $syncRoles || $syncCapabilities;
742+
743+
if ($anySyncPerformed) {
744+
// Create event objects
745+
$permissionsEvent = new PermissionsSynced($permissionsCreated, $permissionsUpdated, collect());
746+
$rolesEvent = new RolesSynced($rolesCreated, $rolesUpdated, collect());
747+
$capabilitiesEvent = $syncCapabilities
748+
? new CapabilitiesSynced($capabilitiesCreated, $capabilitiesUpdated, collect())
749+
: null;
750+
751+
// Dispatch individual events only for types that were actually synced
752+
if ($syncPermissions) {
753+
event($permissionsEvent);
754+
}
755+
if ($syncRoles) {
756+
event($rolesEvent);
757+
}
758+
if ($capabilitiesEvent) {
759+
event($capabilitiesEvent);
760+
}
761+
762+
// Dispatch combined event
763+
event(new MandateSynced($permissionsEvent, $rolesEvent, $capabilitiesEvent));
764+
}
765+
766+
return new SyncResult(
767+
permissionsCreated: $permissionsCreated,
768+
permissionsUpdated: $permissionsUpdated,
769+
rolesCreated: $rolesCreated,
770+
rolesUpdated: $rolesUpdated,
771+
capabilitiesCreated: $capabilitiesCreated,
772+
capabilitiesUpdated: $capabilitiesUpdated,
773+
assignmentsSeeded: $assignmentsSeeded,
774+
);
775+
}
776+
777+
/**
778+
* Sync definitions to the database for a given entity type.
779+
*
780+
* @param 'permissions'|'roles'|'capabilities' $entityType
781+
* @return array{created: int, updated: int}
782+
*/
783+
private function syncDefinitionsToDatabase(
784+
DefinitionDiscoverer $discoverer,
785+
string $entityType,
786+
?string $guard
787+
): array {
788+
$paths = config("mandate.code_first.paths.{$entityType}", []);
789+
$paths = is_array($paths) ? $paths : [$paths];
790+
791+
// Discover definitions based on entity type
792+
$definitions = match ($entityType) {
793+
'permissions' => $discoverer->discoverPermissions($paths),
794+
'roles' => $discoverer->discoverRoles($paths),
795+
'capabilities' => $discoverer->discoverCapabilities($paths),
796+
};
797+
798+
// Filter by guard if specified
799+
if ($guard !== null) {
800+
$definitions = $definitions->filter(fn ($d) => $d->guard === $guard);
801+
}
802+
803+
// Get the model class and check for label column
804+
$modelConfigKey = match ($entityType) {
805+
'permissions' => 'permission',
806+
'roles' => 'role',
807+
'capabilities' => 'capability',
808+
};
809+
$defaultClass = match ($entityType) {
810+
'permissions' => Permission::class,
811+
'roles' => Role::class,
812+
'capabilities' => Capability::class,
813+
};
814+
815+
/** @var class-string<Permission|Role|Capability> $modelClass */
816+
$modelClass = config("mandate.models.{$modelConfigKey}", $defaultClass);
817+
$hasLabelColumn = $modelClass::hasLabelColumn();
818+
819+
$created = 0;
820+
$updated = 0;
821+
822+
foreach ($definitions as $definition) {
823+
$existing = $modelClass::query()
824+
->where('name', $definition->name)
825+
->where('guard', $definition->guard)
826+
->first();
827+
828+
if ($existing) {
829+
$needsUpdate = false;
830+
$updates = [];
831+
832+
if ($hasLabelColumn) {
833+
if ($definition->label !== null && $existing->label !== $definition->label) {
834+
$updates['label'] = $definition->label;
835+
$needsUpdate = true;
836+
}
837+
if ($definition->description !== null && $existing->description !== $definition->description) {
838+
$updates['description'] = $definition->description;
839+
$needsUpdate = true;
840+
}
841+
}
842+
843+
if ($needsUpdate) {
844+
$existing->update($updates);
845+
$updated++;
846+
}
847+
} else {
848+
$attributes = [
849+
'name' => $definition->name,
850+
'guard' => $definition->guard,
851+
];
852+
853+
if ($hasLabelColumn) {
854+
$attributes['label'] = $definition->label;
855+
$attributes['description'] = $definition->description;
856+
}
857+
858+
$modelClass::query()->create($attributes);
859+
$created++;
860+
}
861+
}
862+
863+
return ['created' => $created, 'updated' => $updated];
864+
}
865+
866+
/**
867+
* Seed role-permission and role-capability assignments from config.
868+
*/
869+
private function seedAssignmentsFromConfig(?string $guard): void
870+
{
871+
/** @var array<string, array{permissions?: array<string>, capabilities?: array<string>}> $assignments */
872+
$assignments = config('mandate.assignments', []);
873+
874+
if (empty($assignments)) {
875+
return;
876+
}
877+
878+
$roleGuard = $guard ?? config('auth.defaults.guard', 'web');
879+
880+
/** @var class-string<Role> $roleClass */
881+
$roleClass = config('mandate.models.role', Role::class);
882+
/** @var class-string<Permission> $permissionClass */
883+
$permissionClass = config('mandate.models.permission', Permission::class);
884+
/** @var class-string<Capability> $capabilityClass */
885+
$capabilityClass = config('mandate.models.capability', Capability::class);
886+
887+
// Collect all unique names needed
888+
$roleNames = array_keys($assignments);
889+
$allPermissionNames = [];
890+
$allCapabilityNames = [];
891+
892+
foreach ($assignments as $assignment) {
893+
if (! empty($assignment['permissions'])) {
894+
$allPermissionNames = array_merge($allPermissionNames, $assignment['permissions']);
895+
}
896+
if (! empty($assignment['capabilities'])) {
897+
$allCapabilityNames = array_merge($allCapabilityNames, $assignment['capabilities']);
898+
}
899+
}
900+
901+
$allPermissionNames = array_unique($allPermissionNames);
902+
$allCapabilityNames = array_unique($allCapabilityNames);
903+
904+
// Batch fetch existing roles
905+
/** @var Collection<int, Role> $existingRoles */
906+
$existingRoles = $roleClass::query()
907+
->whereIn('name', $roleNames)
908+
->where('guard', $roleGuard)
909+
->get()
910+
->keyBy('name');
911+
912+
// Create missing roles
913+
$rolesMap = [];
914+
foreach ($roleNames as $roleName) {
915+
if ($existingRoles->has($roleName)) {
916+
$rolesMap[$roleName] = $existingRoles->get($roleName);
917+
} else {
918+
$rolesMap[$roleName] = $roleClass::create([
919+
'name' => $roleName,
920+
'guard' => $roleGuard,
921+
]);
922+
}
923+
}
924+
925+
// Batch fetch existing permissions (skip if none needed)
926+
$permissionsMap = [];
927+
if (! empty($allPermissionNames)) {
928+
/** @var Collection<int, Permission> $existingPermissions */
929+
$existingPermissions = $permissionClass::query()
930+
->whereIn('name', $allPermissionNames)
931+
->where('guard', $roleGuard)
932+
->get()
933+
->keyBy('name');
934+
935+
// Create missing permissions
936+
foreach ($allPermissionNames as $permissionName) {
937+
if ($existingPermissions->has($permissionName)) {
938+
$permissionsMap[$permissionName] = $existingPermissions->get($permissionName);
939+
} else {
940+
$permissionsMap[$permissionName] = $permissionClass::create([
941+
'name' => $permissionName,
942+
'guard' => $roleGuard,
943+
]);
944+
}
945+
}
946+
}
947+
948+
// Batch fetch existing capabilities (if enabled)
949+
$capabilitiesMap = [];
950+
if ($this->capabilitiesEnabled() && ! empty($allCapabilityNames)) {
951+
/** @var Collection<int, Capability> $existingCapabilities */
952+
$existingCapabilities = $capabilityClass::query()
953+
->whereIn('name', $allCapabilityNames)
954+
->where('guard', $roleGuard)
955+
->get()
956+
->keyBy('name');
957+
958+
foreach ($allCapabilityNames as $capabilityName) {
959+
if ($existingCapabilities->has($capabilityName)) {
960+
$capabilitiesMap[$capabilityName] = $existingCapabilities->get($capabilityName);
961+
} else {
962+
$capabilitiesMap[$capabilityName] = $capabilityClass::create([
963+
'name' => $capabilityName,
964+
'guard' => $roleGuard,
965+
]);
966+
}
967+
}
968+
}
969+
970+
// Assign permissions and capabilities to roles
971+
foreach ($assignments as $roleName => $assignment) {
972+
/** @var Role $role */
973+
$role = $rolesMap[$roleName];
974+
975+
// Sync permissions
976+
if (! empty($assignment['permissions'])) {
977+
$permissionIds = array_map(
978+
fn (string $name) => $permissionsMap[$name]->getKey(),
979+
$assignment['permissions']
980+
);
981+
$role->permissions()->syncWithoutDetaching($permissionIds);
982+
}
983+
984+
// Sync capabilities
985+
if ($this->capabilitiesEnabled() && ! empty($assignment['capabilities'])) {
986+
$capabilityIds = array_map(
987+
fn (string $name) => $capabilitiesMap[$name]->getKey(),
988+
$assignment['capabilities']
989+
);
990+
$role->capabilities()->syncWithoutDetaching($capabilityIds);
991+
}
992+
}
993+
}
994+
652995
/**
653996
* Handle the case when feature handler is not available.
654997
*/

0 commit comments

Comments
 (0)