31 - Add Stream entity with frequency and optional bucket link

This commit is contained in:
myrmidex 2026-06-19 20:11:13 +02:00
parent 016d4f497e
commit a2d36eb4da
4 changed files with 417 additions and 0 deletions

View file

@ -0,0 +1,35 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260619175824 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 stream (id UUID NOT NULL, name VARCHAR(255) NOT NULL, amount INT NOT NULL, type VARCHAR(255) NOT NULL, is_active BOOLEAN DEFAULT true NOT NULL, frequency VARCHAR(255) NOT NULL, start_date DATE NOT NULL, end_date DATE DEFAULT NULL, description TEXT DEFAULT NULL, bucket_id UUID DEFAULT NULL, scenario_id UUID NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_F0E9BE1C84CE584D ON stream (bucket_id)');
$this->addSql('CREATE INDEX IDX_F0E9BE1CE04E49DF ON stream (scenario_id)');
$this->addSql('ALTER TABLE stream ADD CONSTRAINT FK_F0E9BE1C84CE584D FOREIGN KEY (bucket_id) REFERENCES bucket (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE stream ADD CONSTRAINT FK_F0E9BE1CE04E49DF 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 stream DROP CONSTRAINT FK_F0E9BE1C84CE584D');
$this->addSql('ALTER TABLE stream DROP CONSTRAINT FK_F0E9BE1CE04E49DF');
$this->addSql('DROP TABLE stream');
}
}

181
src/Entity/Stream.php Normal file
View file

@ -0,0 +1,181 @@
<?php
namespace App\Entity;
use App\Enum\StreamFrequency;
use App\Enum\StreamType;
use App\Repository\StreamRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\UuidV7;
#[ORM\Table(name: 'stream')]
#[ORM\Entity(repositoryClass: StreamRepository::class)]
class Stream
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private UuidV7 $id;
#[ORM\ManyToOne(targetEntity: Bucket::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Bucket $bucket;
#[ORM\ManyToOne(targetEntity: Scenario::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Scenario $scenario;
#[ORM\Column(length: 255)]
private string $name;
#[ORM\Column]
private int $amount;
#[ORM\Column(enumType: StreamType::class)]
private StreamType $type;
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])]
private bool $isActive = true;
#[ORM\Column(enumType: StreamFrequency::class)]
private StreamFrequency $frequency;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
private \DateTimeImmutable $startDate;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $endDate = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
public function __construct()
{
$this->id = new UuidV7();
}
public function getId(): UuidV7
{
return $this->id;
}
public function getBucket(): ?Bucket
{
return $this->bucket;
}
public function getScenario(): Scenario
{
return $this->scenario;
}
public function getName(): string
{
return $this->name;
}
public function getAmount(): int
{
return $this->amount;
}
public function getType(): StreamType
{
return $this->type;
}
public function isActive(): bool
{
return $this->isActive;
}
public function getFrequency(): StreamFrequency
{
return $this->frequency;
}
public function getStartDate(): \DateTimeImmutable
{
return $this->startDate;
}
public function getEndDate(): ?\DateTimeImmutable
{
return $this->endDate;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setBucket(?Bucket $bucket): static
{
$this->bucket = $bucket;
return $this;
}
public function setScenario(Scenario $scenario): static
{
$this->scenario = $scenario;
return $this;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function setAmount(int $amount): static
{
$this->amount = $amount;
return $this;
}
public function setType(StreamType $type): static
{
$this->type = $type;
return $this;
}
public function setIsActive(bool $isActive): static
{
$this->isActive = $isActive;
return $this;
}
public function setFrequency(StreamFrequency $frequency): static
{
$this->frequency = $frequency;
return $this;
}
public function setStartDate(\DateTimeImmutable $startDate): static
{
$this->startDate = $startDate;
return $this;
}
public function setEndDate(?\DateTimeImmutable $endDate): static
{
$this->endDate = $endDate;
return $this;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
}

View file

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

View file

@ -0,0 +1,158 @@
<?php
namespace App\Tests\Entity;
use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Entity\Stream;
use App\Enum\BucketAllocationType;
use App\Enum\BucketType;
use App\Enum\StreamFrequency;
use App\Enum\StreamType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class StreamPersistenceTest extends KernelTestCase
{
private EntityManagerInterface $em;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
}
public function testItPersistsAStreamLinkedToItsScenarioAndBucket(): void
{
$scenario = new Scenario();
$scenario->setName('Household Budget');
$this->em->persist($scenario);
$bucket = new Bucket();
$bucket->setScenario($scenario);
$bucket->setType(BucketType::NEED);
$bucket->setName('Rent');
$bucket->setPriority(1);
$bucket->setSortOrder(0);
$bucket->setAllocationType(BucketAllocationType::FIXED_LIMIT);
$bucket->setAllocationValue(150000);
$bucket->setStartingAmount(0);
$bucket->setBufferMultiplier('0.00');
$this->em->persist($bucket);
$this->em->flush();
$scenarioId = $scenario->getId();
$bucketId = $bucket->getId();
$stream = new Stream();
$stream->setScenario($scenario);
$stream->setBucket($bucket);
$stream->setName('Paycheck');
$stream->setAmount(250000);
$stream->setType(StreamType::INCOME);
$stream->setFrequency(StreamFrequency::BIWEEKLY);
$stream->setStartDate(new \DateTimeImmutable('2026-01-01'));
$stream->setEndDate(new \DateTimeImmutable('2026-12-31'));
$stream->setIsActive(true);
$stream->setDescription('Primary employer income.');
$this->em->persist($stream);
$this->em->flush();
$streamId = $stream->getId();
$this->em->clear();
$reloaded = $this->em->find(Stream::class, $streamId);
self::assertNotNull($reloaded, 'Stream 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 stream must be linked to the same scenario it was persisted with.',
);
self::assertInstanceOf(Bucket::class, $reloaded->getBucket());
self::assertSame(
(string) $bucketId,
(string) $reloaded->getBucket()->getId(),
'Reloaded stream must be linked to the same bucket it was persisted with.',
);
self::assertSame('Paycheck', $reloaded->getName());
self::assertSame(
250000,
$reloaded->getAmount(),
'amount must round-trip as an int (cents), not a string or float.',
);
self::assertInstanceOf(
StreamType::class,
$reloaded->getType(),
'type must hydrate as a StreamType enum instance, not a raw string.',
);
self::assertSame(StreamType::INCOME, $reloaded->getType());
self::assertInstanceOf(
StreamFrequency::class,
$reloaded->getFrequency(),
'frequency must hydrate as a StreamFrequency enum instance, not a raw string.',
);
self::assertSame(StreamFrequency::BIWEEKLY, $reloaded->getFrequency());
self::assertInstanceOf(\DateTimeImmutable::class, $reloaded->getStartDate());
self::assertSame('2026-01-01', $reloaded->getStartDate()->format('Y-m-d'));
self::assertInstanceOf(\DateTimeImmutable::class, $reloaded->getEndDate());
self::assertSame('2026-12-31', $reloaded->getEndDate()->format('Y-m-d'));
self::assertTrue($reloaded->isActive());
self::assertSame('Primary employer income.', $reloaded->getDescription());
}
public function testItPersistsAStreamWithoutABucket(): void
{
$scenario = new Scenario();
$scenario->setName('Household Budget');
$this->em->persist($scenario);
$this->em->flush();
$stream = new Stream();
$stream->setScenario($scenario);
$stream->setName('Freelance Income');
$stream->setAmount(50000);
$stream->setType(StreamType::INCOME);
$stream->setFrequency(StreamFrequency::MONTHLY);
$stream->setStartDate(new \DateTimeImmutable('2026-02-01'));
$this->em->persist($stream);
$this->em->flush();
$streamId = $stream->getId();
$this->em->clear();
$reloaded = $this->em->find(Stream::class, $streamId);
self::assertNotNull($reloaded, 'Stream must be retrievable from the database after an identity-map clear.');
self::assertNull(
$reloaded->getBucket(),
'bucket must be nullable — a stream is not required to link to a bucket.',
);
self::assertNull(
$reloaded->getEndDate(),
'end_date must be nullable when not explicitly set.',
);
self::assertTrue(
$reloaded->isActive(),
'is_active must default to true when setIsActive() is never called.',
);
}
}