diff --git a/migrations/Version20241008203020.php b/migrations/Version20241008203020.php new file mode 100644 index 0000000..dc64502 --- /dev/null +++ b/migrations/Version20241008203020.php @@ -0,0 +1,37 @@ +addSql('CREATE TABLE package_downloads (package_id INT NOT NULL, historical_data JSON NOT NULL, recent_data JSON NOT NULL, total INT NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, merged_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(package_id))'); + $this->addSql('COMMENT ON COLUMN package_downloads.updated_at IS \'(DC2Type:date_immutable)\''); + $this->addSql('CREATE TABLE version_downloads (version_id INT NOT NULL, historical_data JSON NOT NULL, recent_data JSON NOT NULL, total INT NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, merged_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(version_id))'); + $this->addSql('COMMENT ON COLUMN version_downloads.updated_at IS \'(DC2Type:date_immutable)\''); + $this->addSql('ALTER TABLE package_downloads ADD CONSTRAINT FK_E19D697EF44CABFF FOREIGN KEY (package_id) REFERENCES package (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE version_downloads ADD CONSTRAINT FK_45C4D6DB4BBC2705 FOREIGN KEY (version_id) REFERENCES version (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + + $this->addSql('INSERT INTO package_downloads (package_id, historical_data, recent_data, total) SELECT id, \'{}\', \'{}\', 0 FROM package'); + $this->addSql('INSERT INTO version_downloads (version_id, historical_data, recent_data, total) SELECT id, \'{}\', \'{}\', 0 FROM version'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE package_downloads DROP CONSTRAINT FK_E19D697EF44CABFF'); + $this->addSql('ALTER TABLE version_downloads DROP CONSTRAINT FK_45C4D6DB4BBC2705'); + $this->addSql('DROP TABLE package_downloads'); + $this->addSql('DROP TABLE version_downloads'); + } +} diff --git a/src/Controller/ApiController.php b/src/Controller/ApiController.php index df4686c..da6b619 100644 --- a/src/Controller/ApiController.php +++ b/src/Controller/ApiController.php @@ -6,6 +6,7 @@ use CodedMonkey\Conductor\Doctrine\Entity\Package; use CodedMonkey\Conductor\Doctrine\Repository\PackageRepository; use CodedMonkey\Conductor\Doctrine\Repository\VersionRepository; +use CodedMonkey\Conductor\Message\TrackDownloads; use CodedMonkey\Conductor\Message\UpdatePackage; use CodedMonkey\Conductor\Package\PackageDistributionResolver; use CodedMonkey\Conductor\Package\PackageMetadataResolver; @@ -14,9 +15,12 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Messenger\Stamp\TransportNamesStamp; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\RouterInterface; use function Symfony\Component\String\u; @@ -131,9 +135,25 @@ public function packageDistribution(string $packageName, string $packageVersion, #[Route('/downloads', name: 'api_track_downloads', methods: ['POST'])] #[IsGrantedAccess] - public function trackDownloads(): Response + public function trackDownloads(Request $request): Response { - return new Response(); + $contents = json_decode($request->getContent(), true); + $invalidInputs = static function ($item) { + return !isset($item['name'], $item['version']); + }; + + if (!is_array($contents) || !isset($contents['downloads']) || !is_array($contents['downloads']) || array_filter($contents['downloads'], $invalidInputs)) { + return new JsonResponse(['status' => 'error', 'message' => 'Invalid request format, must be a json object containing a downloads key filled with an array of name/version objects'], 200); + } + + $message = new TrackDownloads($contents['downloads'], new \DateTime()); + $envelope = new Envelope($message, [ + new TransportNamesStamp('async'), + ]); + + $this->messenger->dispatch($envelope); + + return new JsonResponse(['status' => 'success'], Response::HTTP_CREATED); } private function findPackage(string $packageName): ?Package diff --git a/src/Doctrine/Entity/AbstractDownloads.php b/src/Doctrine/Entity/AbstractDownloads.php new file mode 100644 index 0000000..b42e4b5 --- /dev/null +++ b/src/Doctrine/Entity/AbstractDownloads.php @@ -0,0 +1,130 @@ + Data is keyed by date in form of YYYYMMDD and as such the keys are technically seen as ints by PHP + */ + #[ORM\Column(type: 'json')] + protected array $historicalData = []; + + /** + * @var array Data is keyed by date in form of YYYYMMDD and as such the keys are technically seen as ints by PHP + */ + #[ORM\Column(type: 'json')] + protected array $recentData = []; + + #[ORM\Column(type: 'integer')] + protected int $total = 0; + + #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)] + protected ?\DateTimeImmutable $updatedAt = null; + + #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)] + protected ?\DateTimeImmutable $mergedAt = null; + + /** + * @param array $data + */ + public function setData(array $data): void + { + $this->historicalData = $data; + $this->recentData = []; + + $this->mergedAt = new \DateTimeImmutable(); + + $this->data = null; + } + + /** + * @param numeric-string $key + */ + public function setDataPoint(string $key, int $value): void + { + $this->recentData[$key] = $value; + + $this->data = null; + } + + /** + * @return array Key is "YYYYMMDD" which means it always gets converted to an int by php + */ + public function getData(): array + { + if (null === $this->data) { + $this->data = $this->doMergeData(); + } + + return $this->data; + } + + public function setTotal(int $total): void + { + $this->total = $total; + } + + public function getTotal(): int + { + return $this->total; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTimeImmutable $updatedAt): void + { + $this->updatedAt = $updatedAt; + } + + public function getMergedAt(): ?\DateTimeImmutable + { + return $this->mergedAt; + } + + public function mergeData(): void + { + $data = $this->doMergeData(); + + $this->historicalData = $data; + $this->recentData = []; + + $this->mergedAt = new \DateTimeImmutable(); + + $this->data = null; + } + + protected function doMergeData(): array + { + $data = $this->historicalData; + + foreach ($this->recentData as $dataKey => $dataPoint) { + $data[$dataKey] ??= 0; + $data[$dataKey] += $dataPoint; + } + + return $data; + } + + /** + * @param numeric-string $key + */ + public function increase(string $key): void + { + $this->recentData[$key] ??= 0; + $this->recentData[$key]++; + + $this->total++; + + $this->data = null; + $this->updatedAt = new \DateTimeImmutable(); + } +} diff --git a/src/Doctrine/Entity/Package.php b/src/Doctrine/Entity/Package.php index 66e117e..016314f 100644 --- a/src/Doctrine/Entity/Package.php +++ b/src/Doctrine/Entity/Package.php @@ -57,6 +57,12 @@ class Package #[ORM\Column(nullable: true)] private ?string $remoteId = null; + #[ORM\ManyToOne] + private ?Registry $mirrorRegistry = null; + + #[ORM\OneToOne(mappedBy: 'package', cascade: ['persist'])] + private PackageDownloads $downloads; + /** * @var Collection */ @@ -75,9 +81,6 @@ class Package #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)] private ?\DateTimeInterface $dumpedAt = null; - #[ORM\ManyToOne] - private ?Registry $mirrorRegistry = null; - /** * @var array|null lookup table for versions */ @@ -85,6 +88,7 @@ class Package public function __construct() { + $this->downloads = new PackageDownloads($this); $this->versions = new ArrayCollection(); $this->createdAt = new \DateTime(); } @@ -244,6 +248,21 @@ public function setRemoteId(?string $remoteId): void $this->remoteId = $remoteId; } + public function getMirrorRegistry(): ?Registry + { + return $this->mirrorRegistry; + } + + public function setMirrorRegistry(?Registry $mirrorRegistry): void + { + $this->mirrorRegistry = $mirrorRegistry; + } + + public function getDownloads(): PackageDownloads + { + return $this->downloads; + } + /** * @return Collection */ @@ -303,16 +322,6 @@ public function setDumpedAt(?\DateTimeInterface $dumpedAt): void $this->dumpedAt = $dumpedAt; } - public function getMirrorRegistry(): ?Registry - { - return $this->mirrorRegistry; - } - - public function setMirrorRegistry(?Registry $mirrorRegistry): void - { - $this->mirrorRegistry = $mirrorRegistry; - } - public static function sortVersions(Version $a, Version $b): int { $aVersion = $a->getNormalizedVersion(); diff --git a/src/Doctrine/Entity/PackageDownloads.php b/src/Doctrine/Entity/PackageDownloads.php new file mode 100644 index 0000000..3ac72a5 --- /dev/null +++ b/src/Doctrine/Entity/PackageDownloads.php @@ -0,0 +1,23 @@ +package = $package; + } + + public function getPackage(): Package + { + return $this->package; + } +} diff --git a/src/Doctrine/Entity/Version.php b/src/Doctrine/Entity/Version.php index 11e068d..9b88996 100644 --- a/src/Doctrine/Entity/Version.php +++ b/src/Doctrine/Entity/Version.php @@ -112,6 +112,9 @@ class Version #[ORM\ManyToOne(targetEntity: Package::class, inversedBy: 'versions')] private ?Package $package; + #[ORM\OneToOne(mappedBy: 'version', cascade: ['persist'])] + private VersionDownloads $downloads; + #[ORM\Column(type: Types::DATETIME_MUTABLE)] private \DateTimeInterface $createdAt; @@ -130,6 +133,7 @@ public function __construct() $this->replace = new ArrayCollection(); $this->suggest = new ArrayCollection(); $this->tags = new ArrayCollection(); + $this->downloads = new VersionDownloads($this); $this->createdAt = new \DateTime(); } @@ -478,6 +482,11 @@ public function setPackage(Package $package): void $this->package = $package; } + public function getDownloads(): VersionDownloads + { + return $this->downloads; + } + public function getCreatedAt(): \DateTimeInterface { return $this->createdAt; diff --git a/src/Doctrine/Entity/VersionDownloads.php b/src/Doctrine/Entity/VersionDownloads.php new file mode 100644 index 0000000..d95fa64 --- /dev/null +++ b/src/Doctrine/Entity/VersionDownloads.php @@ -0,0 +1,23 @@ +version = $version; + } + + public function getVersion(): Version + { + return $this->version; + } +} diff --git a/src/Message/TrackDownloads.php b/src/Message/TrackDownloads.php new file mode 100644 index 0000000..a5d6663 --- /dev/null +++ b/src/Message/TrackDownloads.php @@ -0,0 +1,12 @@ +format('Ymd'); + + foreach ($message->downloads as $download) { + $package = $this->packageRepository->findOneByName($download['name']); + + if (!$package) { + continue; + } + + $version = $this->versionRepository->findOneBy([ + 'package' => $package, + 'normalizedVersion' => $download['version']] + ); + + if (!$version) { + continue; + } + + $package->getDownloads()->increase($dataKey); + $version->getDownloads()->increase($dataKey); + + $this->entityManager->persist($package); + $this->entityManager->persist($version); + } + + $this->entityManager->flush(); + } +}