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
96 changes: 96 additions & 0 deletions src/Phaseolies/Database/Entity/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class Builder
use InteractsWithNestedRelations;
use InteractsWithConditionBinding, InteractsWithSoftDeleteScope {
InteractsWithSoftDeleteScope::buildWhereClause insteadof InteractsWithConditionBinding;
InteractsWithSoftDeleteScope::buildWhereClause as protected buildSoftDeleteWhereClause;
InteractsWithConditionBinding::buildWhereClause as protected buildConditionWhereClause;
}
use InteractsWithCursorPagination;
Expand Down Expand Up @@ -145,6 +146,13 @@ class Builder
*/
protected bool $withHooks = false;

/**
* The condition that ties this query to the model it was reached from
*
* @var array{0: string, 1: array}|null
*/
protected ?array $relationScope = null;

/**
* @var bool
*/
Expand Down Expand Up @@ -173,6 +181,94 @@ public function __construct(PDO $pdo, string $table, string $modelClass, int $ro
$this->connectionName = $connectionName;
}

/**
* Combine the query's conditions with the relation scope, if there is one
*
* @return array [sql, bindings]
*/
protected function buildWhereClause(): array
{
[$sql, $bindings] = $this->buildSoftDeleteWhereClause();

if ($this->relationScope === null) {
return [$sql, $bindings];
}

[$scopeSql, $scopeBindings] = $this->relationScope;

return [
$sql === '' ? $scopeSql : "({$sql}) AND {$scopeSql}",
array_merge($bindings, $scopeBindings),
];
}

/**
* Limit this query to rows whose column equals the value.
*
* @param string $column
* @param mixed $value
* @return static
*/
public function constrainToColumn(string $column, mixed $value): static
{
if ($value === null) {
return $this->constrainToNothing();
}

$this->relationScope = [
$this->quoteIdentifier($this->tableReference() . '.' . $column) . ' = ?',
[$value],
];

return $this;
}

/**
* Limit this query to rows that the pivot table links to the parent
*
* @param string $pivotTable
* @param string $foreignKey
* @param string $relatedKey
* @param string $ownerKey
* @param mixed $parentKey
* @return static
*/
public function constrainToPivot(
string $pivotTable,
string $foreignKey,
string $relatedKey,
string $ownerKey,
mixed $parentKey
): static {
if ($parentKey === null) {
return $this->constrainToNothing();
}

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

$this->relationScope = [
$quote($this->tableReference() . '.' . $ownerKey)
. ' IN (SELECT ' . $quote($pivotTable . '.' . $relatedKey)
. ' FROM ' . $quote($pivotTable)
. ' WHERE ' . $quote($pivotTable . '.' . $foreignKey) . ' = ?)',
[$parentKey],
];

return $this;
}

/**
* Limit this query to rows that will never match
*
* @return static
*/
private function constrainToNothing(): static
{
$this->relationScope = ['1 = 0', []];

return $this;
}

/**
* Skip model hooks for the next write on every model this builder
* creates or loads.
Expand Down
62 changes: 58 additions & 4 deletions src/Phaseolies/Database/Entity/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -845,7 +845,8 @@ public function linkOne(string $related, ?string $foreignKey = null, ?string $lo
$this->rememberRelation('linkOne', $related, $foreignKey, $localKey);

$query = $this->newRelationBuilder(LinkOne::class, $related);
$query->where($foreignKey, '=', $this->$localKey);
$this->constrainRelation($query, $foreignKey, $this->$localKey);
$query->setRelationParent($this, $foreignKey, $localKey);

return $query;
}
Expand All @@ -868,7 +869,8 @@ public function bindTo(string $related, ?string $foreignKey = null, ?string $loc
$this->rememberRelation('bindTo', $related, $foreignKey, $localKey);

$query = $this->newRelationBuilder(BindTo::class, $related);
$query->where($foreignKey, '=', $this->$localKey);
$this->constrainRelation($query, $foreignKey, $this->$localKey);
$query->setRelationChild($this, $localKey, $foreignKey);

return $query;
}
Expand All @@ -889,7 +891,8 @@ public function linkMany(string $related, ?string $foreignKey = null, ?string $l
$this->rememberRelation('linkMany', $related, $foreignKey, $localKey);

$query = $this->newRelationBuilder(LinkMany::class, $related);
$query->where($foreignKey, '=', $this->$localKey);
$this->constrainRelation($query, $foreignKey, $this->$localKey);
$query->setRelationParent($this, $foreignKey, $localKey);

return $query;
}
Expand Down Expand Up @@ -938,9 +941,24 @@ public function bindToMany(
'parentKey' => $this->getKey()
]);

$query->constrainToPivot($pivotTable, $foreignKey, $relatedKey, $relatedModel->getKeyName(), $this->getKey());

return $query;
}

/**
* Limit a relation query to the rows that point at this model
*
* @param Builder $query
* @param string $foreignKey
* @param mixed $value
* @return void
*/
private function constrainRelation(Builder $query, string $foreignKey, mixed $value): void
{
$query->constrainToColumn($foreignKey, $value);
}

/**
* Remember the relationship that was just defined, for eager loading
*
Expand Down Expand Up @@ -981,13 +999,38 @@ private function newRelationBuilder(string $builder, string $related): Builder
$relatedInstance = app($related);
$connection = $relatedInstance->connection;

return new $builder(
$relation = new $builder(
Database::getPdoInstance($connection),
$relatedInstance->getTable(),
$related,
$relatedInstance->pageSize,
$connection
);

$source = $related::query();

foreach (self::builderProperties() as $property) {
if ($property->isInitialized($source)) {
$property->setValue($relation, $property->getValue($source));
}
}

return $relation;
}

/**
* The instance properties of the query builder, read once
*
* @return array<int, \ReflectionProperty>
*/
private static function builderProperties(): array
{
static $properties = null;

return $properties ??= array_values(array_filter(
(new \ReflectionClass(Builder::class))->getProperties(),
fn (\ReflectionProperty $property) => !$property->isStatic()
));
}

/**
Expand All @@ -1001,6 +1044,17 @@ private static function snakeCase(string $value): string
return strtolower((string) preg_replace('/(?<!^)[A-Z]/', '_$0', $value));
}

/**
* Whether an attribute is listed in the model's $creatable whitelist
*
* @param string $attribute
* @return bool
*/
public function isCreatable(string $attribute): bool
{
return in_array($attribute, $this->creatable, true);
}

/**
* Get the last relation type
*
Expand Down
98 changes: 97 additions & 1 deletion src/Phaseolies/Database/Entity/Relations/BindTo.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,104 @@
namespace Phaseolies\Database\Entity\Relations;

use Phaseolies\Database\Entity\Builder;
use Phaseolies\Database\Entity\Model;

class BindTo extends Builder
{
//
/**
* @var Model|null
*/
protected ?Model $child = null;

/**
* @var string
*/
protected string $childKey = '';

/**
* @var string
*/
protected string $ownerKey = '';

/**
* Remember the model that owns the foreign key, and the columns that tie it to its parent
*
* @param Model $child
* @param string $childKey
* @param string $ownerKey
* @return static
*/
public function setRelationChild(Model $child, string $childKey, string $ownerKey): static
{
$this->child = $child;
$this->childKey = $childKey;
$this->ownerKey = $ownerKey;

return $this;
}

/**
* Point the child at a parent. The child is not saved: call save() on it.
*
* @param Model|int|string $parent
* @return Model The child
* @throws \InvalidArgumentException
* @throws \LogicException
*/
public function associate(Model|int|string $parent): Model
{
$expected = $this->modelClass;

if ($parent instanceof Model && !($parent instanceof $expected)) {
throw new \InvalidArgumentException(
'Expected an instance of ' . $expected . ', got ' . $parent::class . '.'
);
}

$child = $this->childModel();

$value = $parent instanceof Model ? $parent->{$this->ownerKey} : $parent;

if ($value === null) {
throw new \LogicException('Cannot associate an unsaved ' . get_debug_type($parent) . ': its [' . $this->ownerKey . '] is empty.');
}

$child->setAttribute($this->childKey, $value);

return $child;
}

/**
* Remove the link to the parent. The child is not saved: call save() on it.
*
* @return Model The child
* @throws \LogicException
*/
public function dissociate(): Model
{
$child = $this->childModel();

$child->setAttribute($this->childKey, null);

return $child;
}

/**
* @return Model
* @throws \LogicException
*/
private function childModel(): Model
{
if ($this->child === null) {
throw new \LogicException('This relation was not created from a model.');
}

if (!$this->child->isCreatable($this->childKey)) {
throw new \LogicException(
'Add [' . $this->childKey . '] to ' . $this->child::class . '::$creatable so the change can be saved.'
);
}

return $this->child;
}
}
Loading
Loading