Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/Phaseolies/Database/Entity/Attributes/Hook.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
* booting, booted,
* before_created, after_created,
* before_updated, after_updated,
* before_deleted, after_deleted
* before_deleted, after_deleted,
* before_restored, after_restored (#[SoftDeletes] models only)
*
* The 'when' parameter (optional):
* - A string → name of a public/protected method on the model that returns bool
Expand Down
30 changes: 30 additions & 0 deletions src/Phaseolies/Database/Entity/Attributes/SoftDeletes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace Phaseolies\Database\Entity\Attributes;

use Attribute;

/**
* Marks a model as soft-deletable.
*
* Deleting the model stamps the given column instead of removing the row,
* and every query on the model excludes stamped rows unless asked not to.
*
* Usage:
*
* #[SoftDeletes]
* class Post extends Model {}
*
* #[SoftDeletes(column: 'removed_at')]
* class Post extends Model {}
*/
#[Attribute(Attribute::TARGET_CLASS)]
final class SoftDeletes
{
/**
* @param string $column
*/
public function __construct(
public readonly string $column = 'deleted_at',
) {}
}
113 changes: 95 additions & 18 deletions src/Phaseolies/Database/Entity/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
CollectsRelations,
InteractsWithNestedRelations,
InteractsWithConditionBinding,
InteractsWithCursorPagination
InteractsWithCursorPagination,
InteractsWithSoftDeleteScope
};
use Phaseolies\Support\Facades\URL;
use Phaseolies\Support\Contracts\Encryptable;
Expand All @@ -34,7 +35,10 @@ class Builder
use InteractsWithAggregateFucntion;
use CollectsRelations;
use InteractsWithNestedRelations;
use InteractsWithConditionBinding;
use InteractsWithConditionBinding, InteractsWithSoftDeleteScope {
InteractsWithSoftDeleteScope::buildWhereClause insteadof InteractsWithConditionBinding;
InteractsWithConditionBinding::buildWhereClause as protected buildConditionWhereClause;
}
use InteractsWithCursorPagination;

/**
Expand Down Expand Up @@ -294,6 +298,9 @@ public function whereNested(callable $callback, string $boolean = 'AND'): self
{
$nestedQuery = new static($this->pdo, $this->table, $this->modelClass, $this->rowPerPage, $this->connectionName);

// The outer query applies the soft delete constraint once
$nestedQuery->withoutSoftDeleteScope();

$callback($nestedQuery);

if (!empty($nestedQuery->conditions)) {
Expand Down Expand Up @@ -581,18 +588,25 @@ private function buildManyToManySubquery(Model $model, mixed $relatedModel, ?cal

$quote = fn($identifier) => $this->quoteIdentifier($identifier);

$relatedQuery = $this->buildRelatedSubqueryScope($relatedModel, $callback);
$softDeleteConstraint = $relatedQuery->compileSoftDeleteConstraint($relatedTable);

// Build subquery with JOIN to access related model columns
$subquery = "SELECT 1 FROM {$quote($pivotTable)}";

// Add JOIN to related table if we have a callback
if ($callback) {
// Join the related table when its columns are needed
if ($callback || $softDeleteConstraint !== null) {
$subquery .= " INNER JOIN {$quote($relatedTable)} ON {$quote($pivotTable)}.{$quote($relatedKey)} = {$quote($relatedTable)}.{$quote($relatedPrimaryKey)}";
}

$subquery .= " WHERE {$quote($pivotTable)}.{$quote($foreignKey)} = {$quote($this->table)}.{$quote($localKey)}";

if ($callback) {
$subquery = $this->addCallbackConditions($subquery, $relatedModel, $callback, $relatedTable);
$subquery = $this->addCallbackConditions($subquery, $relatedQuery, $relatedTable);
}

if ($softDeleteConstraint !== null) {
$subquery .= " AND {$softDeleteConstraint}";
}

return $subquery;
Expand All @@ -614,30 +628,51 @@ private function buildDirectRelationshipSubquery(Model $model, mixed $relatedMod

$quote = fn($identifier) => $this->quoteIdentifier($identifier);

$relatedQuery = $this->buildRelatedSubqueryScope($relatedModel, $callback);

$subquery = "SELECT 1 FROM {$quote($relatedTable)}
WHERE {$quote($relatedTable)}.{$quote($foreignKey)} = {$quote($this->table)}.{$quote($localKey)}";

if ($callback) {
$subquery = $this->addCallbackConditions($subquery, $relatedModel, $callback, $relatedTable);
$subquery = $this->addCallbackConditions($subquery, $relatedQuery, $relatedTable);
}

if (($softDeleteConstraint = $relatedQuery->compileSoftDeleteConstraint($relatedTable)) !== null) {
$subquery .= " AND {$softDeleteConstraint}";
}

return $subquery;
}

/**
* Build a query for the related model and run the user's callback on it,
* so the subquery can read back its conditions and trashed mode.
*
* @param mixed $relatedModel
* @param callable|null $callback
* @return self
*/
private function buildRelatedSubqueryScope(mixed $relatedModel, ?callable $callback): self
{
$relatedQuery = $relatedModel::query($this->connectionName);

if ($callback) {
$callback($relatedQuery);
}

return $relatedQuery;
}

/**
* Add callback conditions to the subquery
*
* @param string $subquery
* @param mixed $relatedModel
* @param callable $callback
* @param self $subQueryBuilder
* @param string $relatedTable
* @return string
*/
private function addCallbackConditions(string $subquery, mixed $relatedModel, callable $callback, string $relatedTable): string
private function addCallbackConditions(string $subquery, self $subQueryBuilder, string $relatedTable): string
{
$subQueryBuilder = $relatedModel::query($this->connectionName);
$callback($subQueryBuilder);

$quote = fn($identifier) => $this->quoteIdentifier($identifier);
$escapeValue = fn($value) => $this->escapeValue($value);

Expand Down Expand Up @@ -1379,17 +1414,20 @@ public function load($relations, ?callable $callback = null): self
* Reload the current model instance with fresh attributes from the database.
*
* @param string|array $relations
* @return $this|null
* @return Model|null
*/
public function fresh($relations = []): ?self
public function fresh($relations = []): ?Model
{
$model = $this->first();

if (!$model) {
return null;
}

$freshModel = $this->getModel()->newQuery()
$freshQuery = $this->getModel()->newQuery();
$freshQuery->trashed = $this->trashed;

$freshModel = $freshQuery
->where($model->getKeyName(), $model->getKey())
->embed($relations)
->first();
Expand Down Expand Up @@ -1417,6 +1455,8 @@ public function find($id)
/**
* Delete records by their primary keys.
*
* On a soft-deletable model the rows are soft-deleted instead.
*
* @param mixed ...$ids
* @return int
* @throws PDOException
Expand All @@ -1433,13 +1473,31 @@ public function purge(...$ids): int
$primaryKey = $model->getKeyName();

$placeholders = implode(',', array_fill(0, count($ids), '?'));
$sql = "DELETE FROM {$this->table} WHERE {$primaryKey} IN ({$placeholders})";
$bindings = $ids;

if ($this->shouldSoftDelete()) {
$column = $this->getSoftDeleteColumn();
$sets = ["{$column} = ?"];
$now = (string) now();
$bindings = [$now];

if ($model->usesTimestamps()) {
$sets[] = 'updated_at = ?';
$bindings[] = $now;
}

$sql = "UPDATE {$this->table} SET " . implode(', ', $sets)
. " WHERE {$primaryKey} IN ({$placeholders}) AND {$column} IS NULL";
$bindings = array_merge($bindings, $ids);
} else {
$sql = "DELETE FROM {$this->table} WHERE {$primaryKey} IN ({$placeholders})";
}

try {
$stmt = $this->pdo->prepare($sql);

foreach ($ids as $index => $id) {
$stmt->bindValue($index + 1, $id, $this->getPdoParamType($id));
foreach (array_values($bindings) as $index => $value) {
$stmt->bindValue($index + 1, $value, $this->getPdoParamType($value));
}

$stmt->execute();
Expand Down Expand Up @@ -2464,6 +2522,18 @@ public function update(array $attributes): bool
$attributes['updated_at'] = now();
}

return $this->performUpdate($attributes);
}

/**
* Run an UPDATE for the given attributes against the matched rows,
* without touching updated_at.
*
* @param array $attributes
* @return bool
*/
protected function performUpdate(array $attributes): bool
{
$setClause = implode(', ', array_map(fn($key) => "$key = ?", array_keys($attributes)));
$setBindings = array_values($attributes);

Expand Down Expand Up @@ -2505,6 +2575,12 @@ public function from(string $table): self
*/
public function delete(): bool
{
if ($this->shouldSoftDelete()) {
$now = (string) now();

return $this->writeSoftDeleteState($now, $now);
}

$sql = "DELETE FROM {$this->table}";

[$whereSql, $whereBindings] = $this->buildWhereClause();
Expand Down Expand Up @@ -3293,6 +3369,7 @@ public function reset(): self
$this->offset = null;
$this->joins = [];
$this->eagerLoad = [];
$this->trashed = 'exclude';

return $this;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ trait InteractsWithCasting
'lastLocalKey',
'lastRelatedKey',
'lastPivotTable',
'forceDeleting',
];

/**
Expand Down
19 changes: 15 additions & 4 deletions src/Phaseolies/Database/Entity/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@
use Phaseolies\Database\Temporal\InteractsWithTemporal;
use Phaseolies\Database\Entity\Watches\InteractsWithWatches;
use Phaseolies\Database\Entity\Computed\InteractsWithComputedProperties;
use Phaseolies\Database\Entity\SoftDeletes\InteractsWithSoftDeletes;
use Phaseolies\Database\Database;
use Phaseolies\Database\Contracts\Support\Jsonable;
use Phaseolies\Database\Entity\Attributes\Hook;

abstract class Model implements Jsonable, \ArrayAccess, \JsonSerializable, \Stringable
{
use InteractsWithModelQueryProcessing, InteractsWithTemporal, InteractsWithCasting, InteractsWithWatches, InteractsWithComputedProperties;
use InteractsWithModelQueryProcessing, InteractsWithTemporal, InteractsWithCasting, InteractsWithWatches, InteractsWithComputedProperties, InteractsWithSoftDeletes;

/**
* The name of the database table associated with the model.
Expand Down Expand Up @@ -762,6 +763,8 @@ public function offsetUnset($offset): void
/**
* Delete the model from the database.
*
* A #[SoftDeletes] model is soft-deleted; use forceDelete() to remove the row.
*
* @return bool
*/
public function delete(): bool
Expand All @@ -775,9 +778,15 @@ public function delete(): bool
return false;
}

$result = $this->newQuery()
->where($this->primaryKey, $this->attributes[$this->primaryKey])
->delete();
if ($this->usesSoftDeletes() && !$this->forceDeleting) {
$result = $this->performSoftDelete();
} else {
$query = $this->newQuery()
->withoutSoftDeleteScope()
->where($this->primaryKey, $this->attributes[$this->primaryKey]);

$result = $this->usesSoftDeletes() ? $query->forceDelete() : $query->delete();
}

if ($result && self::$isHookShouldBeCalled) {
$this->fireAfterHooks('deleted');
Expand Down Expand Up @@ -1188,6 +1197,7 @@ public function toArray(): array
public function increment(string $column, int $amount = 1, array $extra = []): int
{
$result = $this->newQuery()
->withoutSoftDeleteScope()
->where($this->getKeyName(), '=', $this->getKey())
->increment($column, $amount, $extra);

Expand All @@ -1213,6 +1223,7 @@ public function increment(string $column, int $amount = 1, array $extra = []): i
public function decrement(string $column, int $amount = 1, array $extra = []): int
{
$result = $this->newQuery()
->withoutSoftDeleteScope()
->where($this->getKeyName(), '=', $this->getKey())
->decrement($column, $amount, $extra);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ public function save(): bool
}

$response = $this->newQuery()
->withoutSoftDeleteScope()
->where($this->primaryKey, $this->attributes[$this->primaryKey])
->update($dirtyAttributes);

Expand Down Expand Up @@ -507,6 +508,7 @@ public function update(array $attributes): bool

try {
$result = $this->newQuery()
->withoutSoftDeleteScope()
->where($this->primaryKey, $this->attributes[$this->primaryKey])
->update($dirty);

Expand Down
Loading
Loading