enforce element level hierarchy

This commit is contained in:
Yisroel Baum 2026-08-11 22:50:07 +03:00
parent 25495b6c4d
commit 99e1ba051c
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
5 changed files with 80 additions and 44 deletions

View file

@ -2,14 +2,13 @@
namespace App\Element; namespace App\Element;
use App\Set\Set; use App\Set\SetLevel;
final readonly class CreateElementDto final readonly class CreateElementDto
{ {
public function __construct( public function __construct(
public Set $set,
public string $name, public string $name,
public string $kind, public SetLevel $level,
public ?Element $parentElement, public ?Element $parentElement,
) {} ) {}
} }

View file

@ -3,14 +3,14 @@
namespace App\Element; namespace App\Element;
use App\Set\Set; use App\Set\Set;
use App\Set\SetLevel;
final readonly class Element final readonly class Element
{ {
public function __construct( public function __construct(
private int $id, private int $id,
private string $name, private string $name,
private string $kind, private SetLevel $level,
private Set $set,
private ?Element $parentElement, private ?Element $parentElement,
private int $position, private int $position,
) {} ) {}
@ -27,12 +27,17 @@ final readonly class Element
public function getKind(): string public function getKind(): string
{ {
return $this->kind; return $this->level->getKind();
}
public function getLevel(): SetLevel
{
return $this->level;
} }
public function getSet(): Set public function getSet(): Set
{ {
return $this->set; return $this->level->getSet();
} }
public function getParentElement(): ?Element public function getParentElement(): ?Element

View file

@ -8,9 +8,8 @@ use Illuminate\Database\Eloquent\Model;
/** /**
* @property int $id * @property int $id
* @property int $set_id * @property int $set_level_id
* @property string $name * @property string $name
* @property string $kind
* @property int|null $parent_element_id * @property int|null $parent_element_id
* @property int $position * @property int $position
* *
@ -21,9 +20,8 @@ use Illuminate\Database\Eloquent\Model;
* @mixin \Eloquent * @mixin \Eloquent
*/ */
#[Fillable([ #[Fillable([
'set_id', 'set_level_id',
'name', 'name',
'kind',
'parent_element_id', 'parent_element_id',
'position', 'position',
])] ])]
@ -39,7 +37,7 @@ class ElementModel extends Model
protected function casts(): array protected function casts(): array
{ {
return [ return [
'set_id' => 'integer', 'set_level_id' => 'integer',
'parent_element_id' => 'integer', 'parent_element_id' => 'integer',
'position' => 'integer', 'position' => 'integer',
]; ];

View file

@ -3,27 +3,27 @@
namespace App\Element; namespace App\Element;
use App\Set\Set; use App\Set\Set;
use App\Set\SetRepository; use App\Set\SetLevel;
use App\Set\SetLevelRepository;
use DomainException; use DomainException;
use RuntimeException; use RuntimeException;
class EloquentElementRepository implements ElementRepository class EloquentElementRepository implements ElementRepository
{ {
public function __construct( public function __construct(
private SetRepository $setRepository, private SetLevelRepository $setLevelRepository,
) {} ) {}
public function create(CreateElementDto $dto): Element public function create(CreateElementDto $dto): Element
{ {
$this->validateParentSet($dto); $this->validateLevel($dto);
$position = $this->nextPosition( $position = $this->nextPosition(
$dto->set, $dto->level,
$dto->parentElement, $dto->parentElement,
); );
$model = ElementModel::create([ $model = ElementModel::create([
'set_id' => $dto->set->getId(), 'set_level_id' => $dto->level->getId(),
'name' => $dto->name, 'name' => $dto->name,
'kind' => $dto->kind,
'parent_element_id' => $dto->parentElement?->getId(), 'parent_element_id' => $dto->parentElement?->getId(),
'position' => $position, 'position' => $position,
]); ]);
@ -31,8 +31,7 @@ class EloquentElementRepository implements ElementRepository
return new Element( return new Element(
id: $model->id, id: $model->id,
name: $model->name, name: $model->name,
kind: $model->kind, level: $dto->level,
set: $dto->set,
parentElement: $dto->parentElement, parentElement: $dto->parentElement,
position: $model->position, position: $model->position,
); );
@ -47,14 +46,27 @@ class EloquentElementRepository implements ElementRepository
public function findBySet(Set $set): array public function findBySet(Set $set): array
{ {
$levelsById = [];
foreach ($this->setLevelRepository->findBySet($set) as $level) {
$levelsById[$level->getId()] = $level;
}
if ($levelsById === []) {
return [];
}
$models = ElementModel::query() $models = ElementModel::query()
->where('set_id', $set->getId()) ->whereIn('set_level_id', array_keys($levelsById))
->orderBy('id') ->orderBy('id')
->get(); ->get();
$elements = []; $elements = [];
$elementsById = []; $elementsById = [];
foreach ($models as $model) { foreach ($models as $model) {
$level = $levelsById[$model->set_level_id] ?? null;
if ($level === null) {
throw new RuntimeException('element level not found');
}
$parentElement = null; $parentElement = null;
if ($model->parent_element_id !== null) { if ($model->parent_element_id !== null) {
$parentElement = $elementsById[$model->parent_element_id] $parentElement = $elementsById[$model->parent_element_id]
@ -66,7 +78,7 @@ class EloquentElementRepository implements ElementRepository
$element = $this->toDomainWithRelations( $element = $this->toDomainWithRelations(
model: $model, model: $model,
set: $set, level: $level,
parentElement: $parentElement, parentElement: $parentElement,
); );
$elements[] = $element; $elements[] = $element;
@ -78,8 +90,14 @@ class EloquentElementRepository implements ElementRepository
public function findTopLevelBySet(Set $set): array public function findTopLevelBySet(Set $set): array
{ {
$levels = $this->setLevelRepository->findBySet($set);
if ($levels === []) {
return [];
}
$topLevel = $levels[0];
$models = ElementModel::query() $models = ElementModel::query()
->where('set_id', $set->getId()) ->where('set_level_id', $topLevel->getId())
->whereNull('parent_element_id') ->whereNull('parent_element_id')
->orderBy('position') ->orderBy('position')
->orderBy('id') ->orderBy('id')
@ -89,7 +107,7 @@ class EloquentElementRepository implements ElementRepository
foreach ($models as $model) { foreach ($models as $model) {
$elements[] = $this->toDomainWithRelations( $elements[] = $this->toDomainWithRelations(
model: $model, model: $model,
set: $set, level: $topLevel,
parentElement: null, parentElement: null,
); );
} }
@ -100,7 +118,6 @@ class EloquentElementRepository implements ElementRepository
public function findByParentElement(Element $parentElement): array public function findByParentElement(Element $parentElement): array
{ {
$models = ElementModel::query() $models = ElementModel::query()
->where('set_id', $parentElement->getSet()->getId())
->where('parent_element_id', $parentElement->getId()) ->where('parent_element_id', $parentElement->getId())
->orderBy('position') ->orderBy('position')
->orderBy('id') ->orderBy('id')
@ -108,9 +125,10 @@ class EloquentElementRepository implements ElementRepository
$elements = []; $elements = [];
foreach ($models as $model) { foreach ($models as $model) {
$level = $this->findLevel($model->set_level_id);
$elements[] = $this->toDomainWithRelations( $elements[] = $this->toDomainWithRelations(
model: $model, model: $model,
set: $parentElement->getSet(), level: $level,
parentElement: $parentElement, parentElement: $parentElement,
); );
} }
@ -121,26 +139,44 @@ class EloquentElementRepository implements ElementRepository
/** /**
* @throws DomainException * @throws DomainException
*/ */
private function validateParentSet(CreateElementDto $dto): void private function validateLevel(CreateElementDto $dto): void
{ {
$parentElement = $dto->parentElement; $parentElement = $dto->parentElement;
if ($parentElement === null) { if ($parentElement === null) {
if ($dto->level->getDepth() !== 0) {
throw new DomainException(
'root element must use the first set level',
);
}
return; return;
} }
if ($parentElement->getSet()->getId() !== $dto->set->getId()) { if (
$parentElement->getSet()->getId()
!== $dto->level->getSet()->getId()
) {
throw new DomainException( throw new DomainException(
'parent element must belong to the same set', 'parent element must belong to the same set',
); );
} }
if (
$dto->level->getDepth()
!== $parentElement->getLevel()->getDepth() + 1
) {
throw new DomainException(
'child element must use the next set level',
);
}
} }
private function nextPosition( private function nextPosition(
Set $set, SetLevel $level,
?Element $parentElement, ?Element $parentElement,
): int { ): int {
$query = ElementModel::query() $query = ElementModel::query()
->where('set_id', $set->getId()); ->where('set_level_id', $level->getId());
if ($parentElement === null) { if ($parentElement === null) {
$query->whereNull('parent_element_id'); $query->whereNull('parent_element_id');
} else { } else {
@ -157,7 +193,7 @@ class EloquentElementRepository implements ElementRepository
private function toDomain(ElementModel $model): Element private function toDomain(ElementModel $model): Element
{ {
$set = $this->findSet($model->set_id); $level = $this->findLevel($model->set_level_id);
$parentElement = null; $parentElement = null;
if ($model->parent_element_id !== null) { if ($model->parent_element_id !== null) {
@ -169,33 +205,32 @@ class EloquentElementRepository implements ElementRepository
return $this->toDomainWithRelations( return $this->toDomainWithRelations(
model: $model, model: $model,
set: $set, level: $level,
parentElement: $parentElement, parentElement: $parentElement,
); );
} }
private function toDomainWithRelations( private function toDomainWithRelations(
ElementModel $model, ElementModel $model,
Set $set, SetLevel $level,
?Element $parentElement, ?Element $parentElement,
): Element { ): Element {
return new Element( return new Element(
id: $model->id, id: $model->id,
name: $model->name, name: $model->name,
kind: $model->kind, level: $level,
set: $set,
parentElement: $parentElement, parentElement: $parentElement,
position: $model->position, position: $model->position,
); );
} }
private function findSet(int $id): Set private function findLevel(int $id): SetLevel
{ {
$set = $this->setRepository->find($id); $level = $this->setLevelRepository->find($id);
if ($set !== null) { if ($level !== null) {
return $set; return $level;
} }
throw new RuntimeException('element set not found'); throw new RuntimeException('element level not found');
} }
} }

View file

@ -10,18 +10,17 @@ return new class extends Migration
{ {
Schema::create('elements', function (Blueprint $table): void { Schema::create('elements', function (Blueprint $table): void {
$table->id(); $table->id();
$table->foreignId('set_id') $table->foreignId('set_level_id')
->constrained('sets') ->constrained('set_levels')
->restrictOnDelete(); ->restrictOnDelete();
$table->string('name'); $table->string('name');
$table->string('kind');
$table->foreignId('parent_element_id') $table->foreignId('parent_element_id')
->nullable() ->nullable()
->constrained('elements') ->constrained('elements')
->restrictOnDelete(); ->restrictOnDelete();
$table->unsignedInteger('position'); $table->unsignedInteger('position');
$table->index([ $table->index([
'set_id', 'set_level_id',
'parent_element_id', 'parent_element_id',
'position', 'position',
]); ]);