From 016d4f497ef815fefc450a02bd6bd79a4c621a81 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Fri, 19 Jun 2026 01:04:38 +0200 Subject: [PATCH] 31 - Add Bucket entity with scenario relation and priority constraint --- migrations/Version20260618224747.php | 33 +++++ src/Entity/Bucket.php | 165 +++++++++++++++++++++++++ src/Repository/BucketRepository.php | 43 +++++++ tests/Entity/BucketPersistenceTest.php | 128 +++++++++++++++++++ 4 files changed, 369 insertions(+) create mode 100644 migrations/Version20260618224747.php create mode 100644 src/Entity/Bucket.php create mode 100644 src/Repository/BucketRepository.php create mode 100644 tests/Entity/BucketPersistenceTest.php diff --git a/migrations/Version20260618224747.php b/migrations/Version20260618224747.php new file mode 100644 index 0000000..ff5cc26 --- /dev/null +++ b/migrations/Version20260618224747.php @@ -0,0 +1,33 @@ +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'); + } +} diff --git a/src/Entity/Bucket.php b/src/Entity/Bucket.php new file mode 100644 index 0000000..5084446 --- /dev/null +++ b/src/Entity/Bucket.php @@ -0,0 +1,165 @@ + 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; + } +} diff --git a/src/Repository/BucketRepository.php b/src/Repository/BucketRepository.php new file mode 100644 index 0000000..e3794f3 --- /dev/null +++ b/src/Repository/BucketRepository.php @@ -0,0 +1,43 @@ + + */ +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() + // ; + // } +} diff --git a/tests/Entity/BucketPersistenceTest.php b/tests/Entity/BucketPersistenceTest.php new file mode 100644 index 0000000..b93b0b6 --- /dev/null +++ b/tests/Entity/BucketPersistenceTest.php @@ -0,0 +1,128 @@ +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(); + } +}