Initial commit
This commit is contained in:
500
skills/symfony-skill/references/doctrine-advanced.md
Normal file
500
skills/symfony-skill/references/doctrine-advanced.md
Normal file
@@ -0,0 +1,500 @@
|
||||
# Doctrine Advanced Patterns & Optimization
|
||||
|
||||
## Query Optimization Techniques
|
||||
|
||||
### 1. Eager Loading (Avoiding N+1 Problem)
|
||||
|
||||
```php
|
||||
// Bad: N+1 queries
|
||||
$products = $repository->findAll();
|
||||
foreach ($products as $product) {
|
||||
echo $product->getCategory()->getName(); // Extra query for each product
|
||||
}
|
||||
|
||||
// Good: Eager loading with JOIN
|
||||
$products = $repository->createQueryBuilder('p')
|
||||
->leftJoin('p.category', 'c')
|
||||
->addSelect('c')
|
||||
->leftJoin('p.tags', 't')
|
||||
->addSelect('t')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
```
|
||||
|
||||
### 2. Partial Objects
|
||||
|
||||
```php
|
||||
// Load only specific fields
|
||||
$query = $em->createQuery('
|
||||
SELECT partial p.{id, name, price}
|
||||
FROM App\Entity\Product p
|
||||
WHERE p.active = true
|
||||
');
|
||||
$products = $query->getResult();
|
||||
```
|
||||
|
||||
### 3. Query Result Cache
|
||||
|
||||
```php
|
||||
$query = $em->createQuery('SELECT p FROM App\Entity\Product p')
|
||||
->useQueryCache(true)
|
||||
->useResultCache(true, 3600, 'products_list')
|
||||
->setResultCacheDriver($cache);
|
||||
```
|
||||
|
||||
## Advanced Mapping
|
||||
|
||||
### Inheritance Mapping
|
||||
|
||||
#### Single Table Inheritance
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\InheritanceType("SINGLE_TABLE")]
|
||||
#[ORM\DiscriminatorColumn(name: "type", type: "string")]
|
||||
#[ORM\DiscriminatorMap(["person" => Person::class, "employee" => Employee::class])]
|
||||
class Person
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
protected ?int $id = null;
|
||||
|
||||
#[ORM\Column]
|
||||
protected ?string $name = null;
|
||||
}
|
||||
|
||||
#[ORM\Entity]
|
||||
class Employee extends Person
|
||||
{
|
||||
#[ORM\Column]
|
||||
private ?string $department = null;
|
||||
}
|
||||
```
|
||||
|
||||
#### Class Table Inheritance
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\InheritanceType("JOINED")]
|
||||
#[ORM\DiscriminatorColumn(name: "discr", type: "string")]
|
||||
#[ORM\DiscriminatorMap(["person" => Person::class, "employee" => Employee::class])]
|
||||
class Person
|
||||
{
|
||||
// Base class fields
|
||||
}
|
||||
```
|
||||
|
||||
### Embeddables
|
||||
|
||||
```php
|
||||
#[ORM\Embeddable]
|
||||
class Address
|
||||
{
|
||||
#[ORM\Column]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private ?string $zipCode = null;
|
||||
}
|
||||
|
||||
#[ORM\Entity]
|
||||
class User
|
||||
{
|
||||
#[ORM\Embedded(class: Address::class)]
|
||||
private ?Address $address = null;
|
||||
}
|
||||
```
|
||||
|
||||
## Repository Patterns
|
||||
|
||||
### Specification Pattern
|
||||
|
||||
```php
|
||||
interface Specification
|
||||
{
|
||||
public function modify(QueryBuilder $qb, string $alias): void;
|
||||
}
|
||||
|
||||
class ActiveProductSpecification implements Specification
|
||||
{
|
||||
public function modify(QueryBuilder $qb, string $alias): void
|
||||
{
|
||||
$qb->andWhere("$alias.active = :active")
|
||||
->setParameter('active', true);
|
||||
}
|
||||
}
|
||||
|
||||
class PriceRangeSpecification implements Specification
|
||||
{
|
||||
public function __construct(
|
||||
private float $min,
|
||||
private float $max
|
||||
) {}
|
||||
|
||||
public function modify(QueryBuilder $qb, string $alias): void
|
||||
{
|
||||
$qb->andWhere("$alias.price BETWEEN :min AND :max")
|
||||
->setParameter('min', $this->min)
|
||||
->setParameter('max', $this->max);
|
||||
}
|
||||
}
|
||||
|
||||
// Repository implementation
|
||||
class ProductRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function findBySpecifications(array $specifications): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('p');
|
||||
|
||||
foreach ($specifications as $specification) {
|
||||
$specification->modify($qb, 'p');
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
$products = $repository->findBySpecifications([
|
||||
new ActiveProductSpecification(),
|
||||
new PriceRangeSpecification(10.00, 100.00)
|
||||
]);
|
||||
```
|
||||
|
||||
### Custom Hydration
|
||||
|
||||
```php
|
||||
class SimpleArrayHydrator extends AbstractHydrator
|
||||
{
|
||||
protected function hydrateAllData()
|
||||
{
|
||||
$result = [];
|
||||
foreach ($this->_stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$result[] = $row;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
// Register hydrator
|
||||
$em->getConfiguration()->addCustomHydrationMode(
|
||||
'SimpleArrayHydrator',
|
||||
SimpleArrayHydrator::class
|
||||
);
|
||||
|
||||
// Use custom hydrator
|
||||
$query = $em->createQuery('SELECT p.name, p.price FROM App\Entity\Product p');
|
||||
$results = $query->getResult('SimpleArrayHydrator');
|
||||
```
|
||||
|
||||
## Database Migrations
|
||||
|
||||
### Complex Migrations
|
||||
|
||||
```php
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20240101120000 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// DDL changes
|
||||
$this->addSql('ALTER TABLE product ADD discount_price DECIMAL(10, 2) DEFAULT NULL');
|
||||
|
||||
// Data migration
|
||||
$this->addSql('UPDATE product SET discount_price = price * 0.9 WHERE on_sale = true');
|
||||
}
|
||||
|
||||
public function postUp(Schema $schema): void
|
||||
{
|
||||
// Complex data migrations after schema change
|
||||
$connection = $this->connection;
|
||||
$products = $connection->fetchAllAssociative('SELECT id, price FROM product');
|
||||
|
||||
foreach ($products as $product) {
|
||||
// Complex calculation
|
||||
$newPrice = $this->calculateNewPrice($product['price']);
|
||||
$connection->update('product',
|
||||
['calculated_price' => $newPrice],
|
||||
['id' => $product['id']]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE product DROP discount_price');
|
||||
}
|
||||
|
||||
private function calculateNewPrice(float $price): float
|
||||
{
|
||||
// Complex business logic
|
||||
return $price * 1.1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Event Listeners & Lifecycle Callbacks
|
||||
|
||||
### Entity Listeners
|
||||
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\EntityListeners([ProductListener::class])]
|
||||
class Product
|
||||
{
|
||||
// Entity code
|
||||
}
|
||||
|
||||
class ProductListener
|
||||
{
|
||||
#[ORM\PrePersist]
|
||||
public function prePersist(Product $product, LifecycleEventArgs $args): void
|
||||
{
|
||||
$product->setCreatedAt(new \DateTimeImmutable());
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function preUpdate(Product $product, PreUpdateEventArgs $args): void
|
||||
{
|
||||
if ($args->hasChangedField('price')) {
|
||||
$oldPrice = $args->getOldValue('price');
|
||||
$newPrice = $args->getNewValue('price');
|
||||
|
||||
// Log price change
|
||||
$this->logger->info('Price changed', [
|
||||
'product' => $product->getId(),
|
||||
'old' => $oldPrice,
|
||||
'new' => $newPrice
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Doctrine Event Subscriber
|
||||
|
||||
```php
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
use Doctrine\ORM\Events;
|
||||
use Doctrine\Persistence\Event\LifecycleEventArgs;
|
||||
|
||||
class TimestampableSubscriber implements EventSubscriber
|
||||
{
|
||||
public function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
Events::prePersist,
|
||||
Events::preUpdate,
|
||||
];
|
||||
}
|
||||
|
||||
public function prePersist(LifecycleEventArgs $args): void
|
||||
{
|
||||
$entity = $args->getObject();
|
||||
|
||||
if ($entity instanceof TimestampableInterface) {
|
||||
$entity->setCreatedAt(new \DateTimeImmutable());
|
||||
$entity->setUpdatedAt(new \DateTimeImmutable());
|
||||
}
|
||||
}
|
||||
|
||||
public function preUpdate(LifecycleEventArgs $args): void
|
||||
{
|
||||
$entity = $args->getObject();
|
||||
|
||||
if ($entity instanceof TimestampableInterface) {
|
||||
$entity->setUpdatedAt(new \DateTimeImmutable());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Queries
|
||||
|
||||
### Native SQL Queries
|
||||
|
||||
```php
|
||||
$rsm = new ResultSetMappingBuilder($em);
|
||||
$rsm->addRootEntityFromClassMetadata(Product::class, 'p');
|
||||
|
||||
$sql = "
|
||||
SELECT p.*
|
||||
FROM product p
|
||||
WHERE MATCH(p.name, p.description) AGAINST(:search IN BOOLEAN MODE)
|
||||
ORDER BY MATCH(p.name, p.description) AGAINST(:search) DESC
|
||||
";
|
||||
|
||||
$query = $em->createNativeQuery($sql, $rsm);
|
||||
$query->setParameter('search', $searchTerm);
|
||||
$results = $query->getResult();
|
||||
```
|
||||
|
||||
### DQL Custom Functions
|
||||
|
||||
```php
|
||||
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
|
||||
|
||||
class MatchAgainst extends FunctionNode
|
||||
{
|
||||
protected $columns = [];
|
||||
protected $needle;
|
||||
protected $mode;
|
||||
|
||||
public function parse(\Doctrine\ORM\Query\Parser $parser): void
|
||||
{
|
||||
$parser->match(Lexer::T_IDENTIFIER);
|
||||
$parser->match(Lexer::T_OPEN_PARENTHESIS);
|
||||
|
||||
$this->columns[] = $parser->StateFieldPathExpression();
|
||||
|
||||
while ($parser->getLexer()->isNextToken(Lexer::T_COMMA)) {
|
||||
$parser->match(Lexer::T_COMMA);
|
||||
$this->columns[] = $parser->StateFieldPathExpression();
|
||||
}
|
||||
|
||||
$parser->match(Lexer::T_COMMA);
|
||||
$this->needle = $parser->StringPrimary();
|
||||
|
||||
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
|
||||
}
|
||||
|
||||
public function getSql(\Doctrine\ORM\Query\SqlWalker $sqlWalker): string
|
||||
{
|
||||
$columns = [];
|
||||
foreach ($this->columns as $column) {
|
||||
$columns[] = $column->dispatch($sqlWalker);
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'MATCH(%s) AGAINST(%s IN BOOLEAN MODE)',
|
||||
implode(', ', $columns),
|
||||
$this->needle->dispatch($sqlWalker)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Register function
|
||||
$config->addCustomStringFunction('MATCH', MatchAgainst::class);
|
||||
```
|
||||
|
||||
## Batch Processing
|
||||
|
||||
```php
|
||||
class BatchProcessor
|
||||
{
|
||||
private const BATCH_SIZE = 100;
|
||||
|
||||
public function processBatch(EntityManagerInterface $em): void
|
||||
{
|
||||
$offset = 0;
|
||||
|
||||
while (true) {
|
||||
$products = $em->getRepository(Product::class)
|
||||
->createQueryBuilder('p')
|
||||
->setFirstResult($offset)
|
||||
->setMaxResults(self::BATCH_SIZE)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
if (empty($products)) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($products as $product) {
|
||||
// Process product
|
||||
$this->processProduct($product);
|
||||
}
|
||||
|
||||
$em->flush();
|
||||
$em->clear(); // Detach entities to free memory
|
||||
|
||||
$offset += self::BATCH_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
public function iterableProcess(EntityManagerInterface $em): void
|
||||
{
|
||||
$query = $em->getRepository(Product::class)
|
||||
->createQueryBuilder('p')
|
||||
->getQuery();
|
||||
|
||||
foreach ($query->toIterable() as $product) {
|
||||
$this->processProduct($product);
|
||||
|
||||
if (($i++ % self::BATCH_SIZE) === 0) {
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
}
|
||||
}
|
||||
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database Connection Management
|
||||
|
||||
### Multiple Entity Managers
|
||||
|
||||
```yaml
|
||||
# config/packages/doctrine.yaml
|
||||
doctrine:
|
||||
dbal:
|
||||
default_connection: default
|
||||
connections:
|
||||
default:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
analytics:
|
||||
url: '%env(resolve:ANALYTICS_DATABASE_URL)%'
|
||||
|
||||
orm:
|
||||
default_entity_manager: default
|
||||
entity_managers:
|
||||
default:
|
||||
connection: default
|
||||
mappings:
|
||||
App:
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
analytics:
|
||||
connection: analytics
|
||||
mappings:
|
||||
Analytics:
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Entity/Analytics'
|
||||
prefix: 'App\Entity\Analytics'
|
||||
```
|
||||
|
||||
### Read/Write Splitting
|
||||
|
||||
```yaml
|
||||
doctrine:
|
||||
dbal:
|
||||
connections:
|
||||
default:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
replicas:
|
||||
replica1:
|
||||
url: '%env(resolve:DATABASE_REPLICA1_URL)%'
|
||||
replica2:
|
||||
url: '%env(resolve:DATABASE_REPLICA2_URL)%'
|
||||
```
|
||||
|
||||
## Performance Best Practices
|
||||
|
||||
1. **Use indexes properly**: Add indexes on frequently queried columns
|
||||
2. **Avoid SELECT ***: Only select needed columns
|
||||
3. **Use pagination**: Don't load all records at once
|
||||
4. **Cache metadata**: Cache entity metadata in production
|
||||
5. **Use read-only entities**: Mark entities as read-only when possible
|
||||
6. **Lazy loading vs Eager loading**: Choose based on use case
|
||||
7. **Use DTO for read operations**: Avoid hydrating full entities
|
||||
8. **Monitor slow queries**: Use Doctrine profiler in development
|
||||
9. **Use transactions wisely**: Group related operations
|
||||
10. **Clear entity manager**: Clear EM in batch operations
|
||||
Reference in New Issue
Block a user