api-platform / demo

Demo app for the API Platform framework
https://demo.api-platform.com
MIT License
295 stars 197 forks source link

test #414

Closed wisdom-diala closed 2 months ago

wisdom-diala commented 2 months ago
Q A
Branch? main for features / current stable version branch for bug fixes
Tickets Closes #..., closes #...
License MIT
Doc PR api-platform/docs#...

Pull Request: Enhance Book Entity and API with Promotion Status and Slug, Implement Review Analysis Command

Description

This PR introduces several enhancements and new features to the Book entity and API, as well as a console command for review analysis. The changes are categorized into three main sections: Migrations and Entity Changes, API Validation/Serialization, and Console Command. Each section is outlined below with detailed descriptions of the changes and implementations.

1. Migrations + Entity Changes

  1. Add isPromoted Field to Book Entity:
    • A boolean field isPromoted has been added to the Book entity.
    • All existing Book records are initialized with isPromoted = false. Below is the migration file created for it:

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration;

/**

  1. Modify isPromoted to promotionStatus:
    • The isPromoted field has been changed to promotionStatus, which can have values: None, Basic, and Pro. A custom enumType was created to ensure that it only accept these three values:
      
      # src/Enum/BookPromotionStatus

namespace App\Enum;

enum BookPromotionStatus: string { case None = 'None'; case Basic = 'Basic'; case Pro = 'Pro'; }

Then added this code to the Book entity to implement the enumType created for promotionStatus:
```php
    #[Assert\NotNull(message: 'Promotion status cannot be null')]
    #[Assert\Choice(choices: [BookPromotionStatus::None, BookPromotionStatus::Basic, BookPromotionStatus::Pro], message: 'Invalid promotion status.')]
    #[ORM\Column(name: '`promotion_status`', type: 'string', enumType: BookPromotionStatus::class)]
    private ?BookPromotionStatus $promotionStatus = BookPromotionStatus::None;

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration;

/**


3. **Add Unique `slug` Field to Book Entity:**
   - A new `slug` field, which must be unique, has been added to the Book entity.
   - For existing Book records, the `slug` is set to `'book-%id%'`, where `%id%` is the Book ID.
   This is the migration file that handles this:
```php
declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
 * Auto-generated Migration: Please modify to your needs!
 */
final class Version20240621112857 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('ALTER TABLE book ADD slug VARCHAR(255)');

        // Populate the `slug` column with default values
        $books = $this->connection->fetchAllAssociative('SELECT id FROM book');
        foreach ($books as $book) {
            $this->addSql('UPDATE book SET slug = ? WHERE id = ?', ['book-' . $book['id'], $book['id']]);
        }

        // Alter the `slug` column to be NOT NULL
        $this->addSql('ALTER TABLE book ALTER COLUMN slug SET NOT NULL');

        // Add a unique constraint to the `slug` column
        $this->addSql('CREATE UNIQUE INDEX UNIQ_CBE5A331989D9B62 ON book (slug)');
    }

    public function down(Schema $schema): void
    {
        // this down() migration is auto-generated, please modify it to your needs
        $this->addSql('DROP INDEX UNIQ_CBE5A331989D9B62');
        $this->addSql('ALTER TABLE book DROP COLUMN slug');
    }
}

2. API Validation/Serialization

  1. Book Creation (POST /admin/books):

    • The promotionStatus field is now mandatory for the Book resource. The API ensures that only the allowed values (None, Basic, Pro) are accepted.
    • The slug field is also mandatory and must be at least 5 characters long. It can contain only lowercase Latin letters, numbers, or hyphens. Added this to the Book.php entity to ensure that the right input was used for the slug.
      #[Assert\NotNull(message: 'Slug cannot be null')]
      #[Assert\Length(min: 5, minMessage: 'Slug must be at least 5 characters long')]
      #[Assert\Regex(pattern: '/^[a-z0-9-]+$/', message: 'Slug can only contain lowercase Latin letters, numbers, or hyphens')]
      #[Groups(groups: ['Book:read', 'Book:write', 'Book:read:admin'])]
      #[ORM\Column(name: '`slug`', type: 'string')]
  2. Update Book Serialization:

    • The slug field is now visible in the API and accessible to everyone.
    • The promotionStatus field is visible in the API but accessible only to administrators (OIDC_ADMIN).

3. Console Command

  1. Review Analysis Command:

    • A new console command has been created to display the day (format Y-m-d) with the highest number of reviews published. If there are multiple days with the same maximum number of reviews, the most recent day is displayed.
  2. Enhanced Command with InputOption:

    • The command is enhanced with an InputOption to display the month (format Y-m) with the highest number of reviews published. If multiple months have the same maximum number of reviews, the most recent month is displayed.

Here are the files created/adjusted for the console command Console Command File

# src/Command/ReviewAnalysisCommand

declare(strict_types=1);

namespace App\Command;

use App\Repository\ReviewRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'app:review-analysis',
    description: 'Displays the day or month with the highest number of reviews published',
)]
final class ReviewAnalysisCommand extends Command
{
    public function __construct(
        private readonly ReviewRepository $reviewRepository
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addOption(
                name: 'group-by-month',
                mode: InputOption::VALUE_NONE,
                description: 'Group reviews by month instead of day'
            );
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $groupByMonth = $input->getOption('group-by-month');

        $result = $groupByMonth
            ? $this->reviewRepository->findHighestReviewMonth()
            : $this->reviewRepository->findHighestReviewDay();

        if ($groupByMonth) {
            $io->success(sprintf('The month with the highest number of reviews is: %s', $result));
        } else {
            $io->success(sprintf('The day with the highest number of reviews is: %s', $result));
        }

        return Command::SUCCESS;
    }
}

Custom DQL files DateStringDQL: This was used to extract date in this format (YYYY-MM-DD) from the reviews timestamp File Path: src/Doctrine/DateStringDQL.php

declare(strict_types=1);

namespace App\Doctrine;

use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\TokenType;

class DateStringDQL extends FunctionNode
{
    public $date;
    // public $format;

    public function getSql(SqlWalker $sqlWalker): string
    {
        return 'TO_CHAR(' . $this->date->dispatch($sqlWalker) . ", 'YYYY-MM-DD')";
    }

    public function parse(Parser $parser): void
    {
        $parser->match(TokenType::T_IDENTIFIER);
        $parser->match(TokenType::T_OPEN_PARENTHESIS);
        $this->date = $parser->ArithmeticPrimary();
        $parser->match(TokenType::T_CLOSE_PARENTHESIS);
    }
}

SubStringDQL: This was used to extract only the year and month in this format (YYYY-MM) from the reviews timestamp File Path: src/Doctrine/DateStringDQL.php

declare(strict_types=1);

namespace App\Doctrine;

use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\TokenType;

class SubstringDQL extends FunctionNode
{
    public $date;
    // public $format;

    public function getSql(SqlWalker $sqlWalker): string
    {
        return 'TO_CHAR(' . $this->date->dispatch($sqlWalker) . ", 'YYYY-MM')";
    }

    public function parse(Parser $parser): void
    {
        $parser->match(TokenType::T_IDENTIFIER);
        $parser->match(TokenType::T_OPEN_PARENTHESIS);
        $this->date = $parser->ArithmeticPrimary();
        $parser->match(TokenType::T_CLOSE_PARENTHESIS);
    }
}

The config/packages/doctrine.yaml was edited to register the custom DQL functions:

dql:
            string_functions:
                DATE: App\Doctrine\DateStringDQL
                SUBSTRING: App\Doctrine\SubstringDQL

And finally this two methods was added to the ReviewRepository.php file.

public function findHighestReviewDay(): ?string
    {
        $qb = $this->createQueryBuilder('r')
            ->select('DATE(r.publishedAt) as dayDate, COUNT(r.id) as review_count')
            ->groupBy('dayDate')
            ->orderBy('review_count', 'DESC')
            ->addOrderBy('dayDate', 'DESC')
            ->setMaxResults(1);

        $result = $qb->getQuery()->getOneOrNullResult();
        $review_count = $result['review_count'];
        return $result['dayDate'] . " with $review_count reviews";
    }

    public function findHighestReviewMonth(): ?string
    {
        $qb = $this->createQueryBuilder('r')
            ->select('SUBSTRING(r.publishedAt) as monthDate, COUNT(r.id) as review_count')
            ->groupBy('monthDate')
            ->orderBy('review_count', 'DESC')
            ->addOrderBy('monthDate', 'DESC')
            ->setMaxResults(1);

        $result = $qb->getQuery()->getOneOrNullResult();
        $review_count = $result['review_count'];
        return $result['monthDate'] . " with $review_count reviews";
    }

Migration Steps

  1. Run the migration to add the isPromoted field to the Book entity and initialize existing records.
  2. Run the migration to change isPromoted to promotionStatus and convert existing values.
  3. Run the migration to add the unique slug field and set the value for existing records.

Testing and Validation

Console Command without option

docker-compose exec php php bin/console app:review-analysis

Console Command with option

docker-compose exec php php bin/console app:review-analysis --group-by-month
vincentchalamon commented 2 months ago

Hi @wisdom-diala,

Thanks for this PR.

This project only exists to promote the API Platform framework, and must include basic and not-complexe features based on API Platform. Sadly, your Pull Request doesn't add any API Platform feature to promote. Also, you're versionning the vendor directory which is considered as a bad practice by the community.

If you have any idea to improve this project by promoting any API Platform feature, feel free to open an issue, I'll be happy to talk about it.

wisdom-diala commented 2 months ago

Hi @wisdom-diala,

Thanks for this PR.

This project only exists to promote the API Platform framework, and must include basic and not-complexe features based on API Platform. Sadly, your Pull Request doesn't add any API Platform feature to promote. Also, you're versionning the vendor directory which is considered as a bad practice by the community.

If you have any idea to improve this project by promoting any API Platform feature, feel free to open an issue, I'll be happy to talk about it.

Sorry, this is for a testing purpose, wasn't expecting it to be merged.