31 - Add Bucket entity with scenario relation and priority constraint
This commit is contained in:
parent
7b87c6d665
commit
016d4f497e
4 changed files with 369 additions and 0 deletions
33
migrations/Version20260618224747.php
Normal file
33
migrations/Version20260618224747.php
Normal 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
165
src/Entity/Bucket.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Repository/BucketRepository.php
Normal file
43
src/Repository/BucketRepository.php
Normal 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()
|
||||||
|
// ;
|
||||||
|
// }
|
||||||
|
}
|
||||||
128
tests/Entity/BucketPersistenceTest.php
Normal file
128
tests/Entity/BucketPersistenceTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue