diff --git a/app/Models/Concerns/AuditableThroughParent.php b/app/Models/Concerns/AuditableThroughParent.php new file mode 100644 index 00000000..47856c7c --- /dev/null +++ b/app/Models/Concerns/AuditableThroughParent.php @@ -0,0 +1,22 @@ + + */ + public function getAuditParentRelation(): BelongsTo; +} diff --git a/app/Models/Concerns/CustomAuditable.php b/app/Models/Concerns/CustomAuditable.php index 150bfb21..1557c0c0 100644 --- a/app/Models/Concerns/CustomAuditable.php +++ b/app/Models/Concerns/CustomAuditable.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models\Concerns; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Config; use OwenIt\Auditing\Auditable; @@ -27,6 +28,36 @@ trait CustomAuditable */ public function getAuditOwnerOrganizationId(): ?string { + if ($this instanceof AuditableThroughParent) { + $relation = $this->getAuditParentRelation(); + $parentId = $this->getAttributes()[$relation->getForeignKeyName()] ?? null; + if ($parentId === null) { + return null; + } + + // Note: The loaded parent is only used if it still matches the foreign key and was loaded with the organization + $relationName = $relation->getRelationName(); + if ($this->relationLoaded($relationName)) { + $parent = $this->getRelation($relationName); + if ($parent instanceof Model + && $parent->getAttribute($relation->getOwnerKeyName()) === $parentId + && array_key_exists('organization_id', $parent->getAttributes())) { + /** @var string|null $organizationId */ + $organizationId = $parent->getAttributes()['organization_id']; + + return $organizationId; + } + } + + /** @var string|null $organizationId */ + $organizationId = $relation->getRelated()->newQuery() + ->toBase() + ->where($relation->getOwnerKeyName(), $parentId) + ->value('organization_id'); + + return $organizationId; + } + return $this->getAttributes()['organization_id'] ?? null; } diff --git a/app/Models/ProjectMember.php b/app/Models/ProjectMember.php index 5d45095d..1bff0042 100644 --- a/app/Models/ProjectMember.php +++ b/app/Models/ProjectMember.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Concerns\AuditableThroughParent; use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\HasUuids; use Database\Factories\ProjectMemberFactory; @@ -29,7 +30,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * @method static Builder whereBelongsToOrganization(Organization $organization) * @method static ProjectMemberFactory factory() */ -class ProjectMember extends Model implements AuditableContract +class ProjectMember extends Model implements AuditableContract, AuditableThroughParent { use CustomAuditable; @@ -83,11 +84,11 @@ class ProjectMember extends Model implements AuditableContract }); } - public function getAuditOwnerOrganizationId(): ?string + /** + * @return BelongsTo + */ + public function getAuditParentRelation(): BelongsTo { - /** @var string|null $organizationId */ - $organizationId = Project::query()->whereKey($this->project_id)->value('organization_id'); - - return $organizationId; + return $this->project(); } } diff --git a/tests/Unit/Model/AuditModelTest.php b/tests/Unit/Model/AuditModelTest.php index 6e0950a9..f08daa85 100644 --- a/tests/Unit/Model/AuditModelTest.php +++ b/tests/Unit/Model/AuditModelTest.php @@ -14,6 +14,7 @@ use App\Models\TimeEntry; use App\Models\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Support\Facades\DB; use PHPUnit\Framework\Attributes\CoversClass; #[CoversClass(Audit::class)] @@ -84,6 +85,41 @@ class AuditModelTest extends ModelTestAbstract $this->assertNull($audit->owner_user_id); } + public function test_owner_organization_through_parent_uses_the_loaded_parent_without_query(): void + { + // Arrange + $organization = Organization::factory()->create(); + $project = Project::factory()->forOrganization($organization)->create(); + $projectMember = ProjectMember::factory()->forProject($project)->forMember(Member::factory()->forOrganization($organization)->create())->create(); + $projectMember->load('project'); + DB::enableQueryLog(); + + // Act + $ownerOrganizationId = $projectMember->getAuditOwnerOrganizationId(); + + // Assert + $this->assertSame($organization->getKey(), $ownerOrganizationId); + $this->assertCount(0, DB::getQueryLog()); + } + + public function test_owner_organization_through_parent_ignores_a_loaded_parent_that_does_not_match_the_foreign_key(): void + { + // Arrange + $organization = Organization::factory()->create(); + $otherOrganization = Organization::factory()->create(); + $project = Project::factory()->forOrganization($organization)->create(); + $otherProject = Project::factory()->forOrganization($otherOrganization)->create(); + $projectMember = ProjectMember::factory()->forProject($project)->forMember(Member::factory()->forOrganization($organization)->create())->create(); + $projectMember->load('project'); + $projectMember->project_id = $otherProject->getKey(); + + // Act + $ownerOrganizationId = $projectMember->getAuditOwnerOrganizationId(); + + // Assert + $this->assertSame($otherOrganization->getKey(), $ownerOrganizationId); + } + public function test_audits_of_an_organization_have_the_organization_itself_as_owner(): void { // Act