Skip to content
Closed
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

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion src/database/src/Concerns/ExplainsQueries.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,23 @@
namespace Hypervel\Database\Concerns;

use Hypervel\Support\Collection;
use InvalidArgumentException;

trait ExplainsQueries
{
/**
* Explains the query.
* Explain the query.
*
* @throws InvalidArgumentException
*/
public function explain(): Collection
{
if ($this->timeout !== null) {
throw new InvalidArgumentException(
'A query timeout cannot be applied to an EXPLAIN statement. Clear the timeout before calling explain().'
);
}

$sql = $this->toSql();

$bindings = $this->getBindings();
Expand Down
24 changes: 12 additions & 12 deletions src/database/src/Eloquent/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -312,9 +312,9 @@ public function except(mixed $models): static
/**
* Add a basic where clause to the query.
*
* @param array|(Closure(static): mixed)|Expression|string $column
* @param array|(Closure(static): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column
*/
public function where(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static
public function where(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static
{
if ($column instanceof Closure && is_null($operator)) {
// @phpstan-ignore argument.type (closure receives Builder instance, static type not required)
Expand All @@ -336,20 +336,20 @@ public function where(array|Closure|Expression|string $column, mixed $operator =
/**
* Add a basic where clause to the query, and return the first result.
*
* @param array|(Closure(static): mixed)|Expression|string $column
* @param array|(Closure(static): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column
* @return null|TModel
*/
public function firstWhere(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): ?Model
public function firstWhere(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): ?Model
{
return $this->where(...func_get_args())->first();
}

/**
* Add an "or where" clause to the query.
*
* @param array|(Closure(static): mixed)|Expression|string $column
* @param array|(Closure(static): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column
*/
public function orWhere(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null): static
public function orWhere(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null): static
{
[$value, $operator] = $this->query->prepareValueAndOperator(
$value,
Expand All @@ -363,27 +363,27 @@ public function orWhere(array|Closure|Expression|string $column, mixed $operator
/**
* Add a basic "where not" clause to the query.
*
* @param array|(Closure(static): mixed)|Expression|string $column
* @param array|(Closure(static): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column
*/
public function whereNot(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static
public function whereNot(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static
{
return $this->where($column, $operator, $value, $boolean . ' not');
}

/**
* Add an "or where not" clause to the query.
*
* @param array|(Closure(static): mixed)|Expression|string $column
* @param array|(Closure(static): mixed)|self|QueryBuilder|Relation<*, *, *>|Expression|string $column
*/
public function orWhereNot(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null): static
public function orWhereNot(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null): static
{
return $this->whereNot($column, $operator, $value, 'or');
}

/**
* Add an "order by" clause for a timestamp to the query.
*/
public function latest(Expression|string|null $column = null): static
public function latest(Closure|self|QueryBuilder|Relation|Expression|string|null $column = null): static
{
if (is_null($column)) {
$column = $this->model->getCreatedAtColumn() ?? 'created_at';
Expand All @@ -397,7 +397,7 @@ public function latest(Expression|string|null $column = null): static
/**
* Add an "order by" clause for a timestamp to the query.
*/
public function oldest(Expression|string|null $column = null): static
public function oldest(Closure|self|QueryBuilder|Relation|Expression|string|null $column = null): static
{
if (is_null($column)) {
$column = $this->model->getCreatedAtColumn() ?? 'created_at';
Expand Down
31 changes: 25 additions & 6 deletions src/database/src/Eloquent/Concerns/QueriesRelationships.php
Original file line number Diff line number Diff line change
Expand Up @@ -775,10 +775,6 @@ public function withAggregate(mixed $relations, Expression|string $column, ?stri
return $this;
}

if (is_null($this->query->columns)) {
$this->query->select([$this->query->from . '.*']);
}

$relations = is_array($relations) ? $relations : [$relations];

foreach ($this->parseWithRelations($relations) as $name => $constraints) {
Expand Down Expand Up @@ -849,6 +845,12 @@ public function withAggregate(mixed $relations, Expression|string $column, ?stri
)
);

$this->assertNoTimeoutOnRelationshipConstraint($query);

if (is_null($this->query->columns)) {
$this->query->select([$this->query->from . '.*']);
}

if ($function === 'exists') {
$this->selectRaw(
sprintf('exists(%s) as %s', $query->toSql(), $this->getQuery()->grammar->wrap($alias)),
Expand Down Expand Up @@ -938,10 +940,13 @@ public function withExists(string|array $relation): static
protected function addHasWhere(Builder $hasQuery, Relation $relation, string $operator, Expression|int $count, string $boolean): static
{
$hasQuery->mergeConstraintsFrom($relation->getQuery());
$query = $hasQuery->toBase();

$this->assertNoTimeoutOnRelationshipConstraint($query);

return $this->canUseExistsForExistenceCheck($operator, $count)
? $this->addWhereExistsQuery($hasQuery->toBase(), $boolean, $operator === '<' && $count === 1)
: $this->addWhereCountQuery($hasQuery->toBase(), $operator, $count, $boolean);
? $this->addWhereExistsQuery($query, $boolean, $operator === '<' && $count === 1)
: $this->addWhereCountQuery($query, $operator, $count, $boolean);
}

/**
Expand Down Expand Up @@ -1002,6 +1007,20 @@ protected function addWhereCountQuery(QueryBuilder $query, string $operator = '>
);
}

/**
* Ensure a relationship constraint does not carry a statement-level timeout.
*
* @throws InvalidArgumentException
*/
protected function assertNoTimeoutOnRelationshipConstraint(QueryBuilder $query): void
{
if ($query->timeout !== null) {
throw new InvalidArgumentException(
'A relationship constraint cannot define its own query timeout. Apply the timeout to the outer query instead.'
);
}
}

/**
* Get the "has relation" base query instance.
*
Expand Down
50 changes: 33 additions & 17 deletions src/database/src/Eloquent/Relations/BelongsToMany.php
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ public function orderByPivotDesc(mixed $column): static
* @return (
* $id is (\Hypervel\Contracts\Support\Arrayable<array-key, mixed>|array<mixed>)
* ? \Hypervel\Database\Eloquent\Collection<int, TRelatedModel&object{pivot: TPivotModel}>
* : TRelatedModel&object{pivot: TPivotModel}
* : TRelatedModel
* )
*/
public function findOrNew(mixed $id, array $columns = ['*']): EloquentCollection|Model
Expand All @@ -574,7 +574,7 @@ public function findOrNew(mixed $id, array $columns = ['*']): EloquentCollection
/**
* Get the first related model record matching the attributes or instantiate it.
*
* @return object{pivot: TPivotModel}&TRelatedModel
* @return TRelatedModel
*/
public function firstOrNew(array $attributes = [], Closure|array $values = []): Model
{
Expand All @@ -588,7 +588,7 @@ public function firstOrNew(array $attributes = [], Closure|array $values = []):
/**
* Get the first record matching the attributes. If the record is not found, create it.
*
* @return object{pivot: TPivotModel}&TRelatedModel
* @return TRelatedModel
*/
public function firstOrCreate(array $attributes = [], Closure|array $values = [], array $joining = [], bool $touch = true): Model
{
Expand All @@ -598,8 +598,10 @@ public function firstOrCreate(array $attributes = [], Closure|array $values = []
} else {
try {
$this->getQuery()->withSavepointIfNeeded(fn () => $this->attach($instance, $joining, $touch));
} catch (UniqueConstraintViolationException) {
// Nothing to do, the model was already attached...
} catch (UniqueConstraintViolationException $exception) {
if (! $this->hasAttachedPivot($instance)) {
throw $exception;
}
}
}
}
Expand All @@ -610,29 +612,43 @@ public function firstOrCreate(array $attributes = [], Closure|array $values = []
/**
* Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record.
*
* @return object{pivot: TPivotModel}&TRelatedModel
* @return TRelatedModel
*/
public function createOrFirst(array $attributes = [], Closure|array $values = [], array $joining = [], bool $touch = true): Model
{
try {
return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, value($values)), $joining, $touch));
} catch (UniqueConstraintViolationException $e) {
} catch (UniqueConstraintViolationException $exception) {
// ...
}

$instance = $this->related->where($attributes)->useWritePdo()->first() ?? throw $exception;

try {
return tap($this->related->where($attributes)->first() ?? throw $e, function ($instance) use ($joining, $touch) {
$this->getQuery()->withSavepointIfNeeded(fn () => $this->attach($instance, $joining, $touch));
});
} catch (UniqueConstraintViolationException $e) {
return (clone $this)->useWritePdo()->where($attributes)->first() ?? throw $e;
$this->getQuery()->withSavepointIfNeeded(fn () => $this->attach($instance, $joining, $touch));
} catch (UniqueConstraintViolationException $attachException) {
if (! $this->hasAttachedPivot($instance)) {
throw $attachException;
}
}

return $instance;
}

/**
* Determine if the related model is attached through the current pivot constraints.
*/
protected function hasAttachedPivot(Model $instance): bool
{
return $this->newPivotStatementForId($instance->getKey())
->useWritePdo()
->exists();
}

/**
* Create or update a related record matching the attributes, and fill it with values.
*
* @return object{pivot: TPivotModel}&TRelatedModel
* @return TRelatedModel
*/
public function updateOrCreate(array $attributes, Closure|array $values = [], array $joining = [], bool $touch = true): Model
{
Expand Down Expand Up @@ -1197,7 +1213,7 @@ public function allRelatedIds(): BaseCollection
* Save a new model and attach it to the parent model.
*
* @param TRelatedModel $model
* @return object{pivot: TPivotModel}&TRelatedModel
* @return TRelatedModel
*/
public function save(Model $model, array $pivotAttributes = [], bool $touch = true): Model
{
Expand All @@ -1212,7 +1228,7 @@ public function save(Model $model, array $pivotAttributes = [], bool $touch = tr
* Save a new model without raising any events and attach it to the parent model.
*
* @param TRelatedModel $model
* @return object{pivot: TPivotModel}&TRelatedModel
* @return TRelatedModel
*/
public function saveQuietly(Model $model, array $pivotAttributes = [], bool $touch = true): Model
{
Expand Down Expand Up @@ -1258,7 +1274,7 @@ public function saveManyQuietly(iterable $models, array $pivotAttributes = []):
/**
* Create a new instance of the related model.
*
* @return object{pivot: TPivotModel}&TRelatedModel
* @return TRelatedModel
*/
public function create(array $attributes = [], array $joining = [], bool $touch = true): Model
{
Expand All @@ -1279,7 +1295,7 @@ public function create(array $attributes = [], array $joining = [], bool $touch
/**
* Create an array of new instances of the related models.
*
* @return array<int, object{pivot: TPivotModel}&TRelatedModel>
* @return array<int, TRelatedModel>
*/
public function createMany(iterable $records, array $joinings = []): array
{
Expand Down
3 changes: 2 additions & 1 deletion src/database/src/Eloquent/Relations/HasOneOrManyThrough.php
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,8 @@ public function createOrFirst(array $attributes = [], Closure|array $values = []
try {
return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, value($values))));
} catch (UniqueConstraintViolationException $exception) {
return $this->where($attributes)->first() ?? throw $exception;
// @phpstan-ignore return.type (generic type lost through where()->first() chain)
return $this->useWritePdo()->where($attributes)->first() ?? throw $exception;
}
}

Expand Down
Loading