31 - Add Bucket entity with scenario relation and priority constraint

This commit is contained in:
myrmidex 2026-06-19 01:04:38 +02:00
parent 7b87c6d665
commit 016d4f497e
4 changed files with 369 additions and 0 deletions

View file

@ -0,0 +1,33 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260618224747 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE bucket (id UUID NOT NULL, name VARCHAR(255) NOT NULL, type VARCHAR(255) NOT NULL, priority INT NOT NULL, sort_order INT DEFAULT 0 NOT NULL, allocation_type VARCHAR(255) NOT NULL, allocation_value INT DEFAULT NULL, starting_amount INT DEFAULT 0 NOT NULL, buffer_multiplier NUMERIC(5, 2) DEFAULT \'0.00\' NOT NULL, scenario_id UUID NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_E73F36A6E04E49DF ON bucket (scenario_id)');
$this->addSql('CREATE UNIQUE INDEX unique_scenario_priority ON bucket (scenario_id, priority)');
$this->addSql('ALTER TABLE bucket ADD CONSTRAINT FK_E73F36A6E04E49DF FOREIGN KEY (scenario_id) REFERENCES scenario (id) ON DELETE CASCADE NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bucket DROP CONSTRAINT FK_E73F36A6E04E49DF');
$this->addSql('DROP TABLE bucket');
}
}

165
src/Entity/Bucket.php Normal file
View file

@ -0,0 +1,165 @@
<?php
namespace App\Entity;
use App\Enum\BucketAllocationType;
use App\Enum\BucketType;
use App\Repository\BucketRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\UuidV7;
#[ORM\Entity(repositoryClass: BucketRepository::class)]
#[ORM\Table(name: 'bucket')]
#[ORM\UniqueConstraint(name: 'unique_scenario_priority', columns: ['scenario_id', 'priority'])]
class Bucket
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private UuidV7 $id;
#[ORM\ManyToOne(targetEntity: Scenario::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Scenario $scenario;
#[ORM\Column(length: 255)]
private string $name;
#[ORM\Column(enumType: BucketType::class)]
private BucketType $type = BucketType::NEED;
#[ORM\Column]
private int $priority;
#[ORM\Column(options: ['default' => 0])]
private int $sortOrder = 0;
#[ORM\Column(enumType: BucketAllocationType::class)]
private BucketAllocationType $allocationType;
#[ORM\Column(nullable: true)]
private ?int $allocationValue = null;
#[ORM\Column(options: ['default' => 0])]
private int $startingAmount = 0;
#[ORM\Column(type: 'decimal', precision: 5, scale: 2, options: ['default' => '0.00'])]
private string $bufferMultiplier = '0.00';
public function __construct()
{
$this->id = new UuidV7();
}
public function getId(): UuidV7
{
return $this->id;
}
public function getScenario(): Scenario
{
return $this->scenario;
}
public function getName(): string
{
return $this->name;
}
public function getType(): BucketType
{
return $this->type;
}
public function getPriority(): int
{
return $this->priority;
}
public function getSortOrder(): int
{
return $this->sortOrder;
}
public function getAllocationType(): BucketAllocationType
{
return $this->allocationType;
}
public function getAllocationValue(): ?int
{
return $this->allocationValue;
}
public function getStartingAmount(): int
{
return $this->startingAmount;
}
public function getBufferMultiplier(): string
{
return $this->bufferMultiplier;
}
public function setScenario(Scenario $scenario): static
{
$this->scenario = $scenario;
return $this;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function setType(BucketType $type): static
{
$this->type = $type;
return $this;
}
public function setPriority(int $priority): static
{
$this->priority = $priority;
return $this;
}
public function setSortOrder(int $sortOrder): static
{
$this->sortOrder = $sortOrder;
return $this;
}
public function setAllocationType(BucketAllocationType $allocationType): static
{
$this->allocationType = $allocationType;
return $this;
}
public function setAllocationValue(?int $allocationValue): static
{
$this->allocationValue = $allocationValue;
return $this;
}
public function setStartingAmount(int $startingAmount): static
{
$this->startingAmount = $startingAmount;
return $this;
}
public function setBufferMultiplier(string $bufferMultiplier): static
{
$this->bufferMultiplier = $bufferMultiplier;
return $this;
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\Bucket;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Bucket>
*/
class BucketRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Bucket::class);
}
// /**
// * @return Bucket[] Returns an array of Bucket objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('b')
// ->andWhere('b.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('b.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?Bucket
// {
// return $this->createQueryBuilder('b')
// ->andWhere('b.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View file

@ -0,0 +1,128 @@
<?php
namespace App\Tests\Entity;
use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Enum\BucketAllocationType;
use App\Enum\BucketType;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class BucketPersistenceTest extends KernelTestCase
{
private EntityManagerInterface $em;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
}
public function testItPersistsABucketLinkedToItsScenario(): void
{
$scenario = new Scenario();
$scenario->setName('Household Budget');
$this->em->persist($scenario);
$this->em->flush();
$scenarioId = $scenario->getId();
$bucket = new Bucket();
$bucket->setScenario($scenario);
$bucket->setType(BucketType::NEED);
$bucket->setName('Rent');
$bucket->setPriority(3);
$bucket->setSortOrder(2);
$bucket->setAllocationType(BucketAllocationType::FIXED_LIMIT);
$bucket->setAllocationValue(150000);
$bucket->setStartingAmount(5000);
$bucket->setBufferMultiplier('1.50');
$this->em->persist($bucket);
$this->em->flush();
$bucketId = $bucket->getId();
$this->em->clear();
$reloaded = $this->em->find(Bucket::class, $bucketId);
self::assertNotNull($reloaded, 'Bucket must be retrievable from the database after an identity-map clear.');
self::assertInstanceOf(Scenario::class, $reloaded->getScenario());
self::assertSame(
(string) $scenarioId,
(string) $reloaded->getScenario()->getId(),
'Reloaded bucket must be linked to the same scenario it was persisted with.',
);
self::assertInstanceOf(
BucketType::class,
$reloaded->getType(),
'type must hydrate as a BucketType enum instance, not a raw string.',
);
self::assertSame(BucketType::NEED, $reloaded->getType());
self::assertSame('Rent', $reloaded->getName());
self::assertSame(3, $reloaded->getPriority());
self::assertSame(2, $reloaded->getSortOrder());
self::assertInstanceOf(
BucketAllocationType::class,
$reloaded->getAllocationType(),
'allocation_type must hydrate as a BucketAllocationType enum instance, not a raw string.',
);
self::assertSame(BucketAllocationType::FIXED_LIMIT, $reloaded->getAllocationType());
self::assertSame(
150000,
$reloaded->getAllocationValue(),
'allocation_value must round-trip as an int (cents), not a string or float.',
);
self::assertSame(5000, $reloaded->getStartingAmount());
self::assertSame(
'1.50',
$reloaded->getBufferMultiplier(),
'buffer_multiplier is a Doctrine decimal column — it hydrates as a numeric string, not a float (precision-safe).',
);
}
public function testScenarioPriorityUniquenessIsEnforced(): void
{
$scenario = new Scenario();
$scenario->setName('Household Budget');
$this->em->persist($scenario);
$this->em->flush();
$first = new Bucket();
$first->setScenario($scenario);
$first->setType(BucketType::NEED);
$first->setName('Rent');
$first->setPriority(3);
$first->setSortOrder(1);
$first->setAllocationType(BucketAllocationType::FIXED_LIMIT);
$first->setAllocationValue(150000);
$first->setStartingAmount(5000);
$first->setBufferMultiplier('0.00');
$this->em->persist($first);
$this->em->flush();
$second = new Bucket();
$second->setScenario($scenario);
$second->setType(BucketType::NEED);
$second->setName('Utilities');
$second->setPriority(3);
$second->setSortOrder(2);
$second->setAllocationType(BucketAllocationType::FIXED_LIMIT);
$second->setAllocationValue(50000);
$second->setStartingAmount(3000);
$second->setBufferMultiplier('0.00');
$this->em->persist($second);
$this->expectException(UniqueConstraintViolationException::class);
$this->em->flush();
}
}