500 lines
12 KiB
Markdown
500 lines
12 KiB
Markdown
# 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 |