Creating Custom Analyzers

Guide pratique pour ajouter un analyzer compatible avec l’API actuelle de Doctrine Doctor.


1. Contrat à respecter

Un analyzer doit:

  • choisir son chemin d’exécution selon ses données d’entrée
  • exposer analyze(QueryDataCollection) ou analyzeMetadata() selon le contrat
  • rester stateless (pas d’état mutable partagé)
  • être enregistré avec le tag doctrine_doctor.analyzer
Contrat Exécution À utiliser pour
AnalyzerInterface Profiler runtime Les constats qui dépendent des requêtes SQL ou du contexte de la requête courante
StaticAnalyzerInterface Commande CI L’analyse du code source ou des mappings sans dépendre du SQL observé pendant une requête
DatabaseAuditAnalyzerInterface Commande CI avec --with-database Les contrôles qui interrogent la base configurée
MetadataAnalyzerInterface Commande CI Contrat historique des contrôles de mapping; il étend StaticAnalyzerInterface

DatabaseAuditAnalyzerInterface étend MetadataAnalyzerInterface; les audits de base de données sont donc désactivés par défaut dans la commande. MetadataAnalyzerInterface utilise MetadataAnalyzerTrait pour adapter analyzeMetadata() au contrat commun.

Pour exécuter les contrôles indépendants des requêtes du profiler dans CI:

php bin/console doctrine:doctor:analyze
php bin/console doctrine:doctor:analyze --with-database --fail-on=warning

La liste des analyzers et leurs chemins d’exécution détaille le classement actuel.

Références:

  • src/Analyzer/AnalyzerInterface.php
  • src/Analyzer/StaticAnalyzerInterface.php
  • src/Analyzer/DatabaseAuditAnalyzerInterface.php
  • src/Analyzer/MetadataAnalyzerInterface.php
  • src/Analyzer/Concern/MetadataAnalyzerTrait.php
  • src/Collection/QueryDataCollection.php
  • src/Collection/IssueCollection.php

2. Exemple minimal

<?php

declare(strict_types=1);

namespace App\Analyzer;

use AhmedBhs\DoctrineDoctor\Analyzer\AnalyzerInterface;
use AhmedBhs\DoctrineDoctor\Collection\IssueCollection;
use AhmedBhs\DoctrineDoctor\Collection\QueryDataCollection;
use AhmedBhs\DoctrineDoctor\DTO\IssueData;
use AhmedBhs\DoctrineDoctor\Factory\IssueFactoryInterface;
use AhmedBhs\DoctrineDoctor\Factory\SuggestionFactoryInterface;
use AhmedBhs\DoctrineDoctor\ValueObject\IssueCategory;
use AhmedBhs\DoctrineDoctor\ValueObject\IssueType;
use AhmedBhs\DoctrineDoctor\ValueObject\Severity;

final class LargeOffsetAnalyzer implements AnalyzerInterface
{
    public function __construct(
        private readonly IssueFactoryInterface $issueFactory,
        private readonly SuggestionFactoryInterface $suggestionFactory,
        private readonly int $offsetThreshold = 10000,
    ) {
    }

    public function analyze(QueryDataCollection $queryDataCollection): IssueCollection
    {
        return IssueCollection::fromGenerator(function () use ($queryDataCollection) {
            foreach ($queryDataCollection as $queryData) {
                if (!str_contains(strtoupper($queryData->sql), ' OFFSET ')) {
                    continue;
                }

                if (!preg_match('/OFFSET\s+(\d+)/i', $queryData->sql, $matches)) {
                    continue;
                }

                $offset = (int) $matches[1];
                if ($offset < $this->offsetThreshold) {
                    continue;
                }

                $issueData = new IssueData(
                    type: IssueType::PERFORMANCE->value,
                    title: sprintf('Large OFFSET detected (%d)', $offset),
                    description: sprintf('Query uses OFFSET %d, which can be expensive on large datasets.', $offset),
                    severity: Severity::warning(),
                    category: IssueCategory::performance(),
                    suggestion: null,
                    queries: [$queryData->sql],
                    backtrace: $queryData->backtrace,
                    data: ['offset' => $offset, 'threshold' => $this->offsetThreshold],
                );

                yield $this->issueFactory->create($issueData);
            }
        });
    }
}

Notes:

  • QueryData expose des propriétés ($queryData->sql, $queryData->backtrace, etc.)
  • les sévérités valides sont critical, warning, info
  • la catégorie métier correspond aux valeurs d’IssueCategory (performance, security, integrity, configuration)

3. Enregistrement du service

# config/services.yaml
services:
    App\Analyzer\LargeOffsetAnalyzer:
        arguments:
            $offsetThreshold: 10000
        tags:
            - { name: 'doctrine_doctor.analyzer' }

4. Configuration utilisateur

Si vous exposez un seuil configurable, ajoutez une clé de config côté bundle puis documentez-la.

Exemple de consommation côté application:

doctrine_doctor:
    analyzers:
        large_offset:
            enabled: true
            offset_threshold: 10000

5. Tests recommandés

Ajouter au minimum:

  1. un test sans violation (aucun issue)
  2. un test avec violation au-dessus du seuil
  3. un test au seuil exact
  4. un test de robustesse (requête inattendue/malformée)

6. Bonnes pratiques

  • un analyzer = une responsabilité claire
  • éviter les faux positifs bruyants
  • produire des messages actionnables
  • ajouter des données utiles dans IssueData::data
  • garder la logique pure et facilement testable

7. Checklist PR

  1. Analyzer implémenté
  2. Service taggé doctrine_doctor.analyzer
  3. Tests ajoutés
  4. Documentation mise à jour (docs/user-guide/analyzers.md, docs/user-guide/execution-modes.md + exemples si utile)
  5. Changelog mis à jour si nécessaire