245 lines
8.2 KiB
PHP
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;
|
|
}
|
|
}
|