precision-soft/doctrine-utility

doctrine utilities library

Maintainers

Package info

github.com/precision-soft/doctrine-utility

pkg:composer/precision-soft/doctrine-utility

Statistics

Installs: 6 183

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v4.0.2 2026-04-06 07:02 UTC

This package is auto-updated.

Last update: 2026-04-06 07:03:59 UTC


README

Doctrine custom types, functions, and services for MySQL.

You may fork and modify it as you wish.

Any suggestions are welcomed.

Usage for AbstractRepository and DoctrineRepository

The purposes for these classes are:

  • easier constructor injection for the repositories; the quotes are because these repositories are actual read services in CRUD methodology
  • code reuse by using custom filters and join filters
  • better find usages for methods because you are forced to implement only what you need

Product.php

<?php

declare(strict_types=1);

namespace Acme\Domain\Product\Entity;

use Doctrine\ORM\Mapping as ORM;
use PrecisionSoft\Doctrine\Utility\Entity\CreatedTrait;
use PrecisionSoft\Doctrine\Utility\Entity\ModifiedTrait;
use PrecisionSoft\Doctrine\Utility\Repository\DoctrineRepository;

#[ORM\Entity(repositoryClass: DoctrineRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[ORM\Table(options: ['collate' => 'utf8mb4_general_ci'])]
class Product
{
    use CreatedTrait;
    use ModifiedTrait;

    #[ORM\Id]
    #[ORM\Column(type: 'integer', options: ['unsigned' => true])]
    #[ORM\GeneratedValue(strategy: 'AUTO')]
    private ?int $id = null;

    #[ORM\Column(type: 'string', length: 64, nullable: false, unique: true)]
    private string $barcode;

    #[ORM\ManyToOne(targetEntity: ProductType::class, fetch: 'EXTRA_LAZY')]
    #[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
    private ProductType $productType;
}

ProductRepository.php

<?php

declare(strict_types=1);

namespace Acme\Domain\Product\Repository;

use Acme\Domain\Product\Entity\Product;
use Acme\Domain\Product\Exception\Exception;
use Acme\Domain\Product\Exception\NotFoundException;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use PrecisionSoft\Doctrine\Utility\Join\JoinCollection;
use PrecisionSoft\Doctrine\Utility\Repository\AbstractRepository;

class ProductRepository extends AbstractRepository
{
    public const JOIN_PRODUCT_TYPE = 'joinProductType';

    protected function getEntityClass(): string
    {
        return Product::class;
    }

    public function find(int $productId): Product
    {
        /** @var Product|null $product */
        $product = $this->getDoctrineRepository()->find($productId);

        if (null === $product) {
            throw new NotFoundException('the product was not found');
        }

        return $product;
    }

    protected function attachCustomFilters(QueryBuilder $queryBuilder, array $filters): JoinCollection
    {
        $joins = new JoinCollection();

        foreach ($filters as $key => $value) {
            switch ($key) {
                case 'barcodeLike':
                    $baseKey = \substr($key, 0, -4);

                    $queryBuilder
                        ->andWhere(static::getAlias() . ".{$baseKey} LIKE :{$key}")
                        ->setParameter($key, $value);

                    break;
                case static::JOIN_PRODUCT_TYPE:
                    $joins->addJoin(
                        new Join(
                            $value,
                            static::getAlias() . '.productType',
                            ProductTypeRepository::getAlias(),
                        ),
                    );

                    break;
                default:
                    throw new Exception(\sprintf('invalid filter `%s` for `%s::%s`', $key, static::class, __FUNCTION__));
            }
        }

        return $joins;
    }
}

DQL Functions

This library provides MySQL-specific DQL functions. Register them in your Doctrine configuration:

# config/packages/doctrine.yaml
doctrine:
    orm:
        dql:
            string_functions:
                JSON_CONTAINS: PrecisionSoft\Doctrine\Utility\Function\JsonContains
                JSON_CONTAINS_PATH: PrecisionSoft\Doctrine\Utility\Function\JsonContainsPath
                JSON_EXTRACT: PrecisionSoft\Doctrine\Utility\Function\JsonExtract
                JSON_SEARCH: PrecisionSoft\Doctrine\Utility\Function\JsonSearch
                JSON_UNQUOTE: PrecisionSoft\Doctrine\Utility\Function\JsonUnquote
                DATE_FORMAT: PrecisionSoft\Doctrine\Utility\Function\DateFormat

Available functions:

Function DQL Usage Description
JSON_CONTAINS JSON_CONTAINS(field, value [, path]) Test whether a JSON document contains a specific value
JSON_CONTAINS_PATH JSON_CONTAINS_PATH(field, 'one'/'all', path [, ...]) Test whether a JSON document contains data at one or more paths
JSON_EXTRACT JSON_EXTRACT(field, path [, ...]) Extract data from a JSON document
JSON_SEARCH JSON_SEARCH(field, 'one'/'all', search [, escape, path...]) Search for a string in a JSON document
JSON_UNQUOTE JSON_UNQUOTE(value) Unquote a JSON value
DATE_FORMAT DATE_FORMAT(date, format) Format a date

MysqlLockService

A service for MySQL named locks (advisory locks) via GET_LOCK() / RELEASE_LOCK().

use PrecisionSoft\Doctrine\Utility\Service\MysqlLockService;

public function __construct(private MysqlLockService $lockService) {}

$lockService->acquire('my-lock', timeout: 5);

$hasLock = $lockService->hasLock('my-lock');

$lockService->release('my-lock');

$lockService->acquireLocks(['lock-a', 'lock-b'], timeout: 5);

$lockService->releaseLocks(['lock-a', 'lock-b']);
$lockService->releaseLocks();

Lock names longer than 64 characters are automatically hashed to fit MySQL's limit. Locks are reference-counted: calling acquire() multiple times with the same name increments a counter, and release() decrements it, only actually releasing the MySQL lock when the count reaches zero.

All errors throw MysqlLockException.

MySqlWalker (USE/FORCE/IGNORE INDEX)

A custom SQL walker for controlling MySQL index hints in DQL queries.

use Doctrine\ORM\Query;
use PrecisionSoft\Doctrine\Utility\Walker\MySqlWalker;

$query = $entityManager->createQuery('...');
$query->setHint(Query::HINT_CUSTOM_OUTPUT_WALKER, MySqlWalker::class);

$query->setHint(MySqlWalker::HINT_USE_INDEX, 'my_index');
$query->setHint(MySqlWalker::HINT_FORCE_INDEX, 'PRIMARY');
$query->setHint(MySqlWalker::HINT_IGNORE_INDEX, 'PRIMARY, other_index');
$query->setHint(MySqlWalker::HINT_IGNORE_INDEX_ON_JOIN, ['my_index', 'joined_table']);
$query->setHint(MySqlWalker::HINT_SELECT_FOR_UPDATE, true);

Index names are validated against [a-zA-Z_][a-zA-Z0-9_]* pattern for safety.

Entity Traits

CreatedTrait

Adds a created column (DATETIME, defaults to CURRENT_TIMESTAMP) with getter/setter.

use PrecisionSoft\Doctrine\Utility\Entity\CreatedTrait;

class MyEntity
{
    use CreatedTrait;
}

ModifiedTrait

Adds a modified column (DATETIME, defaults to CURRENT_TIMESTAMP) with getter/setter and an automatic #[ORM\PreUpdate] callback.

Important: The consuming entity must have the #[ORM\HasLifecycleCallbacks] attribute for the automatic update to work.

use Doctrine\ORM\Mapping as ORM;
use PrecisionSoft\Doctrine\Utility\Entity\ModifiedTrait;

#[ORM\HasLifecycleCallbacks]
class MyEntity
{
    use ModifiedTrait;
}

Dev

git clone git@github.com:precision-soft/doctrine-utility.git
cd doctrine-utility
./dc build && ./dc up -d