landscape-platform/app/Traits/SoftDeleteResolution.php
2025-03-13 19:50:56 -05:00

245 lines
8.2 KiB
PHP

<?php
namespace App\Traits;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
/**
* SoftDeleteResolution Trait
*
* This trait provides functionality for handling conflicts with soft-deleted records
* that violate unique constraints when creating or updating models.
*
* Usage:
* 1. Add this trait to any model that uses SoftDeletes
* 2. Define uniqueFields() method in your model to specify which fields should be considered unique
* 3. Use findSoftDeletedDuplicate() to detect conflicts before saving
* 4. Use restoreSoftDeletedRecord() to restore a soft-deleted record with updated attributes
*
* Example:
* ```
* class User extends Model
* {
* use SoftDeletes, SoftDeleteResolution;
*
* // Define which fields should be considered for uniqueness checks
* protected function uniqueFields(): array
* {
* return ['email'];
* }
* }
*
* // In your controller:
* $newUser = new User(['name' => 'John', 'email' => 'john@example.com']);
*
* // Check for soft-deleted duplicate
* $duplicate = $newUser->findSoftDeletedDuplicate();
*
* if ($duplicate) {
* // Restore with updated attributes
* $restored = $newUser->restoreSoftDeletedRecord($duplicate, ['name' => 'John']);
* return "Restored previously deleted user with updated details.";
* }
*
* // Otherwise, proceed with normal save
* $newUser->save();
* ```
*/
trait SoftDeleteResolution
{
/**
* Check if the current model has the SoftDeletes trait
*
* @return bool
*/
protected function usesSoftDeletes(): bool
{
return in_array(SoftDeletes::class, class_uses_recursive($this));
}
/**
* Get fields that should be considered unique for this model
* This method should be implemented in the model using this trait
*
* @return array An array of field names that should be unique
*/
protected function uniqueFields(): array
{
// Default implementation returns empty array
// Models should override this method to specify their unique fields
return [];
}
/**
* Check if there's a soft-deleted record that would conflict with this one
*
* @param array $attributes Optional custom attributes to check instead of the model's current attributes
* @return Model|null The conflicting soft-deleted model, or null if none exists
*/
public function findSoftDeletedDuplicate(array $attributes = []): ?Model
{
// Ensure the model uses soft deletes
if (!$this->usesSoftDeletes()) {
Log::warning('SoftDeleteResolution trait used on model without SoftDeletes', [
'model' => get_class($this)
]);
return null;
}
// Get unique fields for this model
$uniqueFields = $this->uniqueFields();
if (empty($uniqueFields)) {
Log::warning('No unique fields defined for model using SoftDeleteResolution', [
'model' => get_class($this)
]);
return null;
}
try {
// Use provided attributes or current model attributes
$checkAttributes = empty($attributes) ? $this->getAttributes() : $attributes;
// Build query to find soft-deleted duplicates
$query = static::onlyTrashed();
foreach ($uniqueFields as $field) {
// Only include fields that exist in checkAttributes and have a value
if (array_key_exists($field, $checkAttributes) && !is_null($checkAttributes[$field])) {
$query->where($field, $checkAttributes[$field]);
}
}
// Return the first matching soft-deleted record
return $query->first();
} catch (\Exception $e) {
Log::error('Error checking for soft-deleted duplicates', [
'model' => get_class($this),
'error' => $e->getMessage(),
'attributes' => $attributes ?: $this->getAttributes()
]);
return null;
}
}
/**
* Restore a soft-deleted record and optionally update its attributes
*
* @param Model $model The soft-deleted model to restore
* @param array $attributes New attributes to apply during restoration
* @return Model|null The restored model, or null if restoration failed
*/
public function restoreSoftDeletedRecord(Model $model, array $attributes = []): ?Model
{
// Ensure the model is actually soft-deleted
if (!method_exists($model, 'restore') || is_null($model->deleted_at)) {
Log::warning('Attempted to restore a model that is not soft-deleted', [
'model' => get_class($model),
'id' => $model->getKey()
]);
return null;
}
try {
// Begin transaction to ensure atomicity
DB::beginTransaction();
// Apply any new attributes
if (!empty($attributes)) {
$model->fill($attributes);
}
// Restore the model (removes deleted_at timestamp)
$model->restore();
// Save any updated attributes
if (!empty($attributes)) {
$model->save();
}
// Commit transaction
DB::commit();
Log::info('Successfully restored soft-deleted record', [
'model' => get_class($model),
'id' => $model->getKey(),
'attributes_updated' => !empty($attributes)
]);
return $model;
} catch (\Exception $e) {
// Rollback transaction on error
DB::rollBack();
Log::error('Failed to restore soft-deleted record', [
'model' => get_class($model),
'id' => $model->getKey(),
'error' => $e->getMessage(),
'attributes' => $attributes
]);
return null;
}
}
/**
* Detect if a QueryException is due to a unique constraint violation on a soft-deleted record
*
* @param QueryException $exception The exception to check
* @return bool True if the exception is caused by a conflict with a soft-deleted record
*/
public function isSoftDeletedUniqueConstraintViolation(QueryException $exception): bool
{
// Check if the model uses soft deletes
if (!$this->usesSoftDeletes()) {
return false;
}
// Check if the exception is due to a unique constraint violation
// PostgreSQL error code 23505 is for unique_violation
$isPgsqlUniqueViolation = $exception->getCode() === '23505';
// SQLite and MySQL use different error codes
$isMysqlUniqueViolation = strpos($exception->getMessage(), 'Duplicate entry') !== false;
$isSqliteUniqueViolation = strpos($exception->getMessage(), 'UNIQUE constraint failed') !== false;
// If it's not a unique constraint violation, return false
if (!($isPgsqlUniqueViolation || $isMysqlUniqueViolation || $isSqliteUniqueViolation)) {
return false;
}
// At this point, we have a unique constraint violation
// Now we need to check if the conflicting record is soft-deleted
$uniqueFields = $this->uniqueFields();
if (empty($uniqueFields)) {
return false;
}
// Check if there's a soft-deleted record with the same unique values
return $this->findSoftDeletedDuplicate() !== null;
}
/**
* Attempt to resolve a unique constraint violation by restoring a soft-deleted record
*
* @param array $attributes Attributes for the new or updated record
* @return Model|null The restored model, or null if no resolution was possible
*/
public function resolveSoftDeletedConflict(array $attributes = []): ?Model
{
$duplicate = $this->findSoftDeletedDuplicate($attributes);
if ($duplicate) {
return $this->restoreSoftDeletedRecord($duplicate, $attributes);
}
return null;
}
}