This commit is contained in:
emmymayo
2025-02-05 23:15:46 +01:00
commit 7269c99357
16995 changed files with 3389680 additions and 0 deletions
@@ -0,0 +1,32 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine\Annotations;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\PSRArrayCache;
use MailPoetVendor\Doctrine\Common\Annotations\AnnotationReader;
use MailPoetVendor\Doctrine\Common\Annotations\AnnotationRegistry;
use MailPoetVendor\Doctrine\Common\Annotations\PsrCachedReader;
class AnnotationReaderProvider {
/** @var PsrCachedReader */
private $annotationReader;
public function __construct() {
// register annotation reader if doctrine/annotations package is installed
// (i.e. in dev environment, on production metadata is dumped in the build)
$readAnnotations = class_exists(PsrCachedReader::class) && class_exists(AnnotationReader::class);
if ($readAnnotations) {
// autoload all annotation classes using registered loaders (Composer)
// (needed for Symfony\Validator constraint annotations to be loaded)
AnnotationRegistry::registerLoader('class_exists');
$this->annotationReader = new PsrCachedReader(new AnnotationReader(), new PSRArrayCache());
}
}
public function getAnnotationReader(): ?PsrCachedReader {
return $this->annotationReader;
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,99 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Doctrine\Common\Cache\CacheProvider;
/**
* Array cache
* Based on https://github.com/doctrine/cache/blob/1.11.x/lib/Doctrine/Common/Cache/ArrayCache.php
* The cache implementation was removed from the doctrine/cache v2.0 so we need to provide own implementation.
*/
class ArrayCache extends CacheProvider {
/** @var mixed[] */
private $data = [];
/** @var int */
private $hitsCount = 0;
/** @var int */
private $missesCount = 0;
/** @var int */
private $upTime;
/**
* {@inheritdoc}
*/
public function __construct() {
$this->upTime = time();
}
/**
* {@inheritdoc}
*/
protected function doFetch($id) {
if (!$this->doContains($id)) {
$this->missesCount += 1;
return false;
}
$this->hitsCount += 1;
return $this->data[$id][0];
}
/**
* {@inheritdoc}
*/
protected function doContains($id) {
if (!isset($this->data[$id])) {
return false;
}
$expiration = $this->data[$id][1];
if ($expiration && $expiration < \time()) {
$this->doDelete($id);
return false;
}
return true;
}
/**
* {@inheritdoc}
*/
protected function doSave($id, $data, $lifeTime = 0) {
$this->data[$id] = [$data, $lifeTime ? \time() + $lifeTime : false];
return true;
}
/**
* {@inheritdoc}
*/
protected function doDelete($id) {
unset($this->data[$id]);
return true;
}
/**
* {@inheritdoc}
*/
protected function doFlush() {
$this->data = [];
return true;
}
/**
* {@inheritdoc}
*/
protected function doGetStats() {
return [
CacheProvider::STATS_HITS => $this->hitsCount,
CacheProvider::STATS_MISSES => $this->missesCount,
CacheProvider::STATS_UPTIME => $this->upTime,
CacheProvider::STATS_MEMORY_USAGE => null,
CacheProvider::STATS_MEMORY_AVAILABLE => null,
];
}
}
@@ -0,0 +1,58 @@
<?php declare(strict_types = 1);
namespace MailPoet\Doctrine;
if (!defined('ABSPATH')) exit;
use MailPoet\RuntimeException;
use MailPoetVendor\Doctrine\Persistence\Mapping\ClassMetadata;
use MailPoetVendor\Doctrine\Persistence\Mapping\Driver\MappingDriver;
use MailPoetVendor\Psr\Cache\CacheItemPoolInterface;
/**
* Intended to be used in production environment where we rely on metadata cache for reading all metadata.
*/
class CacheOnlyMappingDriver implements MappingDriver {
/** @var string */
protected $cacheSalt = '__CLASSMETADATA__';
/** @var CacheItemPoolInterface */
private $metaDataCache;
public function __construct(
CacheItemPoolInterface $metaDataCache
) {
$this->metaDataCache = $metaDataCache;
}
/**
* @inerhitDoc
*/
public function loadMetadataForClass($className, ClassMetadata $metadata) {
// We don't need to load anything it is all cached.
}
/**
* @inerhitDoc
*/
public function getAllClassNames() {
throw new RuntimeException('CacheOnlyMappingDriver::getAllClassNames should not be called');
}
/**
* @inerhitDoc
*/
public function isTransient($className) {
// Everything in cache are metadata and class with metadata is non-transient
// See https://github.com/doctrine/persistence/blob/b07e347a24e7a19a2b6462e00a6dff899e4c2dd2/src/Persistence/Mapping/Driver/MappingDriver.php#L34
return !$this->metaDataCache->hasItem($this->getCacheKey($className));
}
/**
* Copy pasted from MailPoetVendor\Doctrine\Persistence\Mapping\AbstractClassMetadataFactory
*/
protected function getCacheKey(string $className): string {
return str_replace('\\', '__', $className) . $this->cacheSalt;
}
}
@@ -0,0 +1,75 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\Annotations\AnnotationReaderProvider;
use MailPoetVendor\Doctrine\Common\Proxy\AbstractProxyFactory;
use MailPoetVendor\Doctrine\ORM\Configuration;
use MailPoetVendor\Doctrine\ORM\Mapping\Driver\AnnotationDriver;
use MailPoetVendor\Doctrine\ORM\Mapping\UnderscoreNamingStrategy;
class ConfigurationFactory {
const ENTITY_DIR = __DIR__ . '/../Entities';
const METADATA_DIR = __DIR__ . '/../../generated/doctrine-metadata';
const PROXY_DIR = __DIR__ . '/../../generated/doctrine-proxies';
const PROXY_NAMESPACE = 'MailPoetDoctrineProxies';
/** @var bool */
private $isDevMode;
/** @var AnnotationReaderProvider */
private $annotationReaderProvider;
public function __construct(
AnnotationReaderProvider $annotationReaderProvider,
$isDevMode = null
) {
$this->isDevMode = $isDevMode === null ? WP_DEBUG : $isDevMode;
$this->annotationReaderProvider = $annotationReaderProvider;
}
public function createConfiguration() {
$configuration = new Configuration();
$configuration->setNamingStrategy(new UnderscoreNamingStrategy(\CASE_LOWER, true));
$this->configureMetadata($configuration);
$this->configureProxies($configuration);
$this->configureCache($configuration);
return $configuration;
}
private function configureMetadata(Configuration $configuration) {
$configuration->setClassMetadataFactoryName(TablePrefixMetadataFactory::class);
// annotation reader exists only in dev environment, on production cache is pre-generated
$annotationReader = $this->annotationReaderProvider->getAnnotationReader();
$isReadOnly = !$annotationReader;
$metadataStorage = new PSRMetadataCache(self::METADATA_DIR, $isReadOnly);
$configuration->setMetadataCache($metadataStorage);
if ($isReadOnly) {
$configuration->setMetadataDriverImpl(new CacheOnlyMappingDriver($metadataStorage));
} else {
$configuration->setMetadataDriverImpl(new AnnotationDriver($annotationReader, [self::ENTITY_DIR]));
}
}
private function configureProxies(Configuration $configuration) {
$configuration->setProxyDir(self::PROXY_DIR);
$configuration->setProxyNamespace(self::PROXY_NAMESPACE);
$configuration->setAutoGenerateProxyClasses(
$this->isDevMode
? AbstractProxyFactory::AUTOGENERATE_FILE_NOT_EXISTS
: AbstractProxyFactory::AUTOGENERATE_NEVER
);
}
private function configureCache(Configuration $configuration) {
$cache = new ArrayCache();
$configuration->setQueryCacheImpl($cache);
$configuration->setResultCacheImpl($cache);
}
}
@@ -0,0 +1,62 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\Middlewares\PostConnectMiddleware;
use MailPoet\Doctrine\Types\BigIntType;
use MailPoet\Doctrine\Types\DateTimeTzToStringType;
use MailPoet\Doctrine\Types\JsonOrSerializedType;
use MailPoet\Doctrine\Types\JsonType;
use MailPoet\Doctrine\Types\SerializedArrayType;
use MailPoet\Doctrine\WPDB\Driver as WPDBDriver;
use MailPoetVendor\Doctrine\DBAL\Configuration;
use MailPoetVendor\Doctrine\DBAL\Driver;
use MailPoetVendor\Doctrine\DBAL\Driver\Middleware;
use MailPoetVendor\Doctrine\DBAL\DriverManager;
use MailPoetVendor\Doctrine\DBAL\Types\Type;
class ConnectionFactory {
const DRIVER_CLASS = WPDBDriver::class;
private $types = [
BigIntType::NAME => BigIntType::class,
DateTimeTzToStringType::NAME => DateTimeTzToStringType::class,
JsonType::NAME => JsonType::class,
JsonOrSerializedType::NAME => JsonOrSerializedType::class,
SerializedArrayType::NAME => SerializedArrayType::class,
];
public function createConnection() {
$this->setupTypes();
return DriverManager::getConnection(
[
'driverClass' => self::DRIVER_CLASS,
],
$this->getConfiguration()
);
}
private function setupTypes() {
foreach ($this->types as $name => $class) {
if (Type::hasType($name)) {
Type::overrideType($name, $class);
} else {
Type::addType($name, $class);
}
}
}
private function getConfiguration(): Configuration {
$config = new Configuration();
$driverMiddleware = new class implements Middleware {
public function wrap(Driver $driver): Driver {
return new PostConnectMiddleware($driver);
}
};
$config->setMiddlewares([$driverMiddleware]);
return $config;
}
}
@@ -0,0 +1,125 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\EventListeners\EmojiEncodingListener;
use MailPoet\Doctrine\EventListeners\LastSubscribedAtListener;
use MailPoet\Doctrine\EventListeners\NewsletterListener;
use MailPoet\Doctrine\EventListeners\SubscriberListener;
use MailPoet\Doctrine\EventListeners\TimestampListener;
use MailPoet\Doctrine\EventListeners\ValidationListener;
use MailPoet\Tracy\DoctrinePanel\DoctrinePanel;
use MailPoetVendor\Doctrine\DBAL\Connection;
use MailPoetVendor\Doctrine\ORM\Configuration;
use MailPoetVendor\Doctrine\ORM\EntityManager;
use MailPoetVendor\Doctrine\ORM\Events;
use Tracy\Debugger;
class EntityManagerFactory {
/** @var Connection */
private $connection;
/** @var Configuration */
private $configuration;
/** @var TimestampListener */
private $timestampListener;
/** @var ValidationListener */
private $validationListener;
/** @var EmojiEncodingListener */
private $emojiEncodingListener;
/** @var LastSubscribedAtListener */
private $lastSubscribedAtListener;
/** @var SubscriberListener */
private $subscriberListener;
private NewsletterListener $newsletterListener;
public function __construct(
Connection $connection,
Configuration $configuration,
TimestampListener $timestampListener,
ValidationListener $validationListener,
EmojiEncodingListener $emojiEncodingListener,
LastSubscribedAtListener $lastSubscribedAtListener,
NewsletterListener $newsletterListener,
SubscriberListener $subscriberListener
) {
$this->connection = $connection;
$this->configuration = $configuration;
$this->timestampListener = $timestampListener;
$this->validationListener = $validationListener;
$this->emojiEncodingListener = $emojiEncodingListener;
$this->lastSubscribedAtListener = $lastSubscribedAtListener;
$this->subscriberListener = $subscriberListener;
$this->newsletterListener = $newsletterListener;
}
public function createEntityManager(): EntityManager {
$entityManager = EntityManager::create($this->connection, $this->configuration);
$this->cleanupListeners($entityManager);
$this->setupListeners($entityManager);
if (
class_exists(Debugger::class)
&& class_exists(DoctrinePanel::class)
) {
DoctrinePanel::init($entityManager);
}
return $entityManager;
}
/**
* We sometimes work with more EntityManager in tests, and the behavior could be inconsistent with multiple listeners
*/
private function cleanupListeners(EntityManager $entityManager) {
$eventManager = $entityManager->getEventManager();
foreach ($eventManager->getListeners() as $event => $listeners) {
if (!is_array($listeners)) {
$eventManager->removeEventListener($event, $listeners);
continue;
}
foreach ($listeners as $listener) {
$eventManager->removeEventListener($event, $listener);
}
}
$entityManager->getConfiguration()->getEntityListenerResolver()->clear(SubscriberListener::class);
}
private function setupListeners(EntityManager $entityManager) {
$entityManager->getEventManager()->addEventListener(
[Events::prePersist, Events::preUpdate],
$this->timestampListener
);
$entityManager->getEventManager()->addEventListener(
[Events::onFlush],
$this->validationListener
);
$entityManager->getEventManager()->addEventListener(
[Events::prePersist, Events::preUpdate],
$this->emojiEncodingListener
);
$entityManager->getEventManager()->addEventListener(
[Events::prePersist, Events::preUpdate],
$this->lastSubscribedAtListener
);
$entityManager->getEventManager()->addEventListener(
[Events::preUpdate],
$this->newsletterListener
);
$entityManager->getConfiguration()->getEntityListenerResolver()->register($this->subscriberListener);
}
}
@@ -0,0 +1,28 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine\EntityTraits;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Doctrine\ORM\Mapping as ORM;
trait AutoincrementedIdTrait {
/**
* @ORM\Column(type="integer")
* @ORM\Id
* @ORM\GeneratedValue
* @var int|null
*/
private $id;
/** @return int|null */
public function getId() {
return $this->id;
}
/** @param int|null $id */
public function setId($id) {
$this->id = $id;
}
}
@@ -0,0 +1,25 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine\EntityTraits;
if (!defined('ABSPATH')) exit;
use DateTimeInterface;
use MailPoetVendor\Doctrine\ORM\Mapping as ORM;
trait CreatedAtTrait {
/**
* @ORM\Column(type="datetimetz", nullable=true)
* @var DateTimeInterface|null
*/
private $createdAt;
public function getCreatedAt(): ?DateTimeInterface {
return $this->createdAt;
}
public function setCreatedAt(DateTimeInterface $createdAt): void {
$this->createdAt = $createdAt;
}
}
@@ -0,0 +1,27 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine\EntityTraits;
if (!defined('ABSPATH')) exit;
use DateTimeInterface;
use MailPoetVendor\Doctrine\ORM\Mapping as ORM;
trait DeletedAtTrait {
/**
* @ORM\Column(type="datetimetz", nullable=true)
* @var DateTimeInterface|null
*/
private $deletedAt;
/** @return DateTimeInterface|null */
public function getDeletedAt() {
return $this->deletedAt;
}
/** @param DateTimeInterface|null $deletedAt */
public function setDeletedAt($deletedAt) {
$this->deletedAt = $deletedAt;
}
}
@@ -0,0 +1,33 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine\EntityTraits;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Doctrine\ORM\EntityNotFoundException;
use MailPoetVendor\Doctrine\ORM\Proxy\Proxy;
trait SafeToOneAssociationLoadTrait {
private function safelyLoadToOneAssociation(string $propertyName, $emptyValue = null) {
if (!property_exists($this, $propertyName)) {
throw new \InvalidArgumentException("Property '$propertyName' does not exist on class '" . get_class($this) . "'");
}
if (!$this->$propertyName instanceof Proxy) {
return;
}
if ($this->$propertyName->__isInitialized()) {
return;
}
// if a proxy exists (= we have related entity ID), try to load it
// to see if it is a valid ID referencing an existing entity
try {
$this->$propertyName->__load();
} catch (EntityNotFoundException $e) {
$this->$propertyName = $emptyValue;
}
}
}
@@ -0,0 +1,26 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine\EntityTraits;
if (!defined('ABSPATH')) exit;
use DateTimeInterface;
use MailPoetVendor\Doctrine\ORM\Mapping as ORM;
trait UpdatedAtTrait {
/**
* @ORM\Column(type="datetimetz")
* @var DateTimeInterface
*/
private $updatedAt;
/** @return DateTimeInterface */
public function getUpdatedAt() {
return $this->updatedAt;
}
public function setUpdatedAt(DateTimeInterface $updatedAt) {
$this->updatedAt = $updatedAt;
}
}
@@ -0,0 +1,21 @@
<?php declare(strict_types = 1);
namespace MailPoet\Doctrine\EntityTraits;
if (!defined('ABSPATH')) exit;
trait ValidationGroupsTrait {
/**
* @var array|null
*/
private $validationGroups;
public function getValidationGroups(): ?array {
return $this->validationGroups;
}
public function setValidationGroups(?array $validationGroups): void {
$this->validationGroups = $validationGroups;
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,39 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine\EventListeners;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\FormEntity;
use MailPoet\WP\Emoji;
use MailPoetVendor\Doctrine\ORM\Event\LifecycleEventArgs;
class EmojiEncodingListener {
/** @var Emoji */
private $emoji;
public function __construct(
Emoji $emoji
) {
$this->emoji = $emoji;
}
public function prePersist(LifecycleEventArgs $eventArgs) {
$this->sanitizeEmojiBeforeSaving($eventArgs);
}
public function preUpdate(LifecycleEventArgs $eventArgs) {
$this->sanitizeEmojiBeforeSaving($eventArgs);
}
private function sanitizeEmojiBeforeSaving(LifecycleEventArgs $eventArgs) {
$entity = $eventArgs->getEntity();
if ($entity instanceof FormEntity) {
$body = $entity->getBody();
if ($body !== null) {
$entity->setBody($this->emoji->sanitizeEmojisInFormBody($body));
}
}
}
}
@@ -0,0 +1,39 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine\EventListeners;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\SubscriberEntity;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\ORM\Event\LifecycleEventArgs;
class LastSubscribedAtListener {
public function prePersist(LifecycleEventArgs $eventArgs): void {
$entity = $eventArgs->getEntity();
if ($entity instanceof SubscriberEntity && $entity->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED) {
$entity->setLastSubscribedAt(Carbon::now()->millisecond(0));
}
}
public function preUpdate(LifecycleEventArgs $eventArgs): void {
$entity = $eventArgs->getEntity();
if (!$entity instanceof SubscriberEntity) {
return;
}
$unitOfWork = $eventArgs->getEntityManager()->getUnitOfWork();
$changeSet = $unitOfWork->getEntityChangeSet($entity);
if (!isset($changeSet['status'])) {
return;
}
[$oldStatus, $newStatus] = $changeSet['status'];
// Update last_subscribed_at when status changes to subscribed
if ($oldStatus !== SubscriberEntity::STATUS_SUBSCRIBED && $newStatus === SubscriberEntity::STATUS_SUBSCRIBED) {
$entity->setLastSubscribedAt(Carbon::now()->millisecond(0));
}
}
}
@@ -0,0 +1,48 @@
<?php declare(strict_types = 1);
namespace MailPoet\Doctrine\EventListeners;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\WpPostEntity;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Doctrine\ORM\Event\LifecycleEventArgs;
class NewsletterListener {
private WPFunctions $wp;
public function __construct(
WPFunctions $wp
) {
$this->wp = $wp;
}
public function preUpdate(LifecycleEventArgs $eventArgs): void {
$entity = $eventArgs->getEntity();
if (!$entity instanceof NewsletterEntity) {
return;
}
$unitOfWork = $eventArgs->getEntityManager()->getUnitOfWork();
/** @var array{status: array{0: string, 1: string}} $changeSet */
$changeSet = $unitOfWork->getEntityChangeSet($entity);
if (!isset($changeSet['status'])) {
return;
}
[$oldStatus, $newStatus] = $changeSet['status'];
if ($oldStatus !== NewsletterEntity::STATUS_SENT && $newStatus === NewsletterEntity::STATUS_SENT) {
$post = $entity->getWpPost();
if ($post instanceof WpPostEntity) {
$this->wp->wpUpdatePost([
'ID' => $post->getId(),
'post_status' => NewsletterEntity::STATUS_SENT,
]);
}
}
}
}
@@ -0,0 +1,45 @@
<?php declare(strict_types = 1);
namespace MailPoet\Doctrine\EventListeners;
if (!defined('ABSPATH')) exit;
use MailPoet\Config\SubscriberChangesNotifier;
use MailPoet\Entities\SubscriberEntity;
use MailPoetVendor\Doctrine\ORM\Event\LifecycleEventArgs;
class SubscriberListener {
/** @var SubscriberChangesNotifier */
private $subscriberChangesNotifier;
public function __construct(
SubscriberChangesNotifier $subscriberChangesNotifier
) {
$this->subscriberChangesNotifier = $subscriberChangesNotifier;
}
private function maybeNotifyStatusChanged(SubscriberEntity $subscriber, LifecycleEventArgs $event): void {
$entityManager = $event->getEntityManager();
$unitOfWork = $entityManager->getUnitOfWork();
$changeset = $unitOfWork->getEntityChangeSet($subscriber);
if (array_key_exists('status', $changeset) && $changeset['status'][0] !== $changeset['status'][1]) {
$this->subscriberChangesNotifier->subscriberStatusChanged((int)$subscriber->getId());
}
}
public function postPersist(SubscriberEntity $subscriber, LifecycleEventArgs $event): void {
$this->subscriberChangesNotifier->subscriberCreated((int)$subscriber->getId());
}
public function postUpdate(SubscriberEntity $subscriber, LifecycleEventArgs $event): void {
$this->subscriberChangesNotifier->subscriberUpdated((int)$subscriber->getId());
$this->maybeNotifyStatusChanged($subscriber, $event);
}
public function postRemove(SubscriberEntity $subscriber, LifecycleEventArgs $event): void {
$this->subscriberChangesNotifier->subscriberDeleted((int)$subscriber->getId());
}
}
@@ -0,0 +1,51 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine\EventListeners;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\EntityTraits\CreatedAtTrait;
use MailPoet\Doctrine\EntityTraits\UpdatedAtTrait;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\ORM\Event\LifecycleEventArgs;
use ReflectionObject;
class TimestampListener {
public function prePersist(LifecycleEventArgs $eventArgs) {
$entity = $eventArgs->getEntity();
$entityTraits = $this->getEntityTraits($entity);
$now = $this->getNow();
if (
in_array(CreatedAtTrait::class, $entityTraits, true)
&& method_exists($entity, 'setCreatedAt')
&& method_exists($entity, 'getCreatedAt')
&& !$entity->getCreatedAt()
) {
$entity->setCreatedAt(clone $now);
}
if (in_array(UpdatedAtTrait::class, $entityTraits, true) && method_exists($entity, 'setUpdatedAt')) {
$entity->setUpdatedAt(clone $now);
}
}
public function preUpdate(LifecycleEventArgs $eventArgs) {
$entity = $eventArgs->getEntity();
$entityTraits = $this->getEntityTraits($entity);
if (in_array(UpdatedAtTrait::class, $entityTraits, true) && method_exists($entity, 'setUpdatedAt')) {
$entity->setUpdatedAt($this->getNow());
}
}
private function getEntityTraits($entity) {
$entityReflection = new ReflectionObject($entity);
return $entityReflection->getTraitNames();
}
public function getNow(): \DateTimeInterface {
return Carbon::now()->millisecond(0);
}
}
@@ -0,0 +1,48 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine\EventListeners;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\Validator\ValidationException;
use MailPoetVendor\Doctrine\ORM\Event\OnFlushEventArgs;
use MailPoetVendor\Symfony\Component\Validator\Validator\ValidatorInterface;
class ValidationListener {
/** @var ValidatorInterface */
private $validator;
public function __construct(
ValidatorInterface $validator
) {
$this->validator = $validator;
}
public function onFlush(OnFlushEventArgs $eventArgs) {
$unitOfWork = $eventArgs->getEntityManager()->getUnitOfWork();
foreach ($unitOfWork->getScheduledEntityInsertions() as $entity) {
$this->validate($entity);
}
foreach ($unitOfWork->getScheduledEntityUpdates() as $entity) {
$this->validate($entity);
}
}
private function validate($entity) {
$groups = $this->getValidationGroups($entity);
$violations = $this->validator->validate($entity, null, $groups);
if ($violations->count() > 0) {
throw new ValidationException(get_class($entity), $violations);
}
}
private function getValidationGroups($entity) {
if (is_object($entity) && method_exists($entity, 'getValidationGroups')) {
return $entity->getValidationGroups();
}
return null;
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,98 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Doctrine\Common\Cache\CacheProvider;
use ReflectionClass;
use ReflectionException;
// Simple filesystem-based cache storage for Doctrine Metadata.
//
// Needed because Doctrine's FilesystemCache doesn't work read-only (when metadata dumped)
// and it calls realpath() that could fail on some hostings due to filesystem permissions.
class MetadataCache extends CacheProvider {
/** @var bool */
private $isDevMode;
/** @var string */
private $directory;
public function __construct(
$dir,
$isReadOnly
) {
$this->isDevMode = defined('WP_DEBUG') && WP_DEBUG && !$isReadOnly;
$this->directory = rtrim($dir, '/\\');
if (!file_exists($this->directory)) {
mkdir($this->directory);
}
}
protected function doFetch($id) {
if (!$this->doContains($id)) {
return false;
}
return unserialize((string)file_get_contents($this->getFilename($id)));
}
protected function doContains($id) {
$filename = $this->getFilename($id);
$fileExists = file_exists($filename);
// in dev mode invalidate cache if source file has changed
if ($fileExists && $this->isDevMode) {
/** @var \stdClass $classMetadata */
$classMetadata = unserialize((string)file_get_contents($filename));
if (!isset($classMetadata->name) || (!class_exists($classMetadata->name) && !interface_exists($classMetadata->name))) {
return false;
}
try {
$reflection = new ReflectionClass($classMetadata->name);
} catch (ReflectionException $e) {
return false;
}
clearstatcache();
return filemtime((string)$filename) >= filemtime((string)$reflection->getFileName());
}
return $fileExists;
}
protected function doSave($id, $data, $lifeTime = 0) {
$filename = $this->getFilename($id);
$result = @file_put_contents($filename, serialize($data));
if ($result === false) {
throw new \RuntimeException("Error while writing to '$filename'");
}
return true;
}
protected function doDelete($id) {
@unlink($this->getFilename($id));
return true;
}
protected function doFlush() {
$directoryContent = glob($this->directory . DIRECTORY_SEPARATOR . '*');
if ($directoryContent === false) {
return false;
}
foreach ($directoryContent as $filename) {
if (is_file($filename)) {
@unlink($filename);
}
}
return true;
}
protected function doGetStats() {
return null;
}
private function getFilename($id) {
return $this->directory . DIRECTORY_SEPARATOR . md5($id);
}
}
@@ -0,0 +1,17 @@
<?php declare(strict_types = 1);
namespace MailPoet\Doctrine\Middlewares;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Doctrine\DBAL\Driver\Connection;
use MailPoetVendor\Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
class PostConnectMiddleware extends AbstractDriverMiddleware {
public function connect(array $params): Connection {
$connection = parent::connect($params);
$connection->exec('SET time_zone = "+00:00"');
return $connection;
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,91 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Psr\Cache\CacheItemInterface;
use MailPoetVendor\Psr\Cache\CacheItemPoolInterface;
class PSRArrayCache implements CacheItemPoolInterface {
/** @var mixed[] */
private $cache = [];
/**
* @inheritDoc
*/
public function getItem($key) {
if (!is_string($key)) {
throw new PSRCacheInvalidArgumentException('Invalid key');
}
if (!$this->hasItem($key)) {
return new PSRCacheItem($key, false);
}
return new PSRCacheItem($key, $this->cache[$key]);
}
public function getItems(array $keys = []) {
return array_map([$this, 'getItem'], $keys);
}
/**
* @inheritDoc
*/
public function hasItem($key) {
return array_key_exists($key, $this->cache);
}
/**
* @inheritDoc
*/
public function clear() {
$this->cache = [];
return true;
}
/**
* @inheritDoc
*/
public function deleteItem($key) {
if (!is_string($key)) {
throw new PSRCacheInvalidArgumentException('Invalid key');
}
unset($this->cache[$key]);
return true;
}
/**
* @inheritDoc
*/
public function deleteItems(array $keys) {
try {
array_map([$this, 'deleteItem'], $keys);
} catch (PSRCacheInvalidArgumentException $e) {
return false;
}
return true;
}
/**
* @inheritDoc
*/
public function save(CacheItemInterface $item) {
$this->cache[$item->getKey()] = $item->get();
return true;
}
/**
* @inheritDoc
*/
public function saveDeferred(CacheItemInterface $item) {
return $this->save($item);
}
/**
* @inheritDoc
*/
public function commit() {
return true;
}
}
@@ -0,0 +1,12 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Psr\Cache\InvalidArgumentException;
class PSRCacheInvalidArgumentException extends \Exception implements InvalidArgumentException {
}
@@ -0,0 +1,72 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Psr\Cache\CacheItemInterface;
class PSRCacheItem implements CacheItemInterface {
/** @var string */
private $key;
/** @var mixed */
private $value;
/** @var bool */
private $isHit;
public function __construct(
string $key,
bool $isHit
) {
$this->key = $key;
$this->isHit = $isHit;
}
/**
* @inheritDoc
*/
public function getKey(): string {
return $this->key;
}
/**
* @inheritDoc
*/
public function get() {
return $this->value;
}
/**
* @inheritDoc
*/
public function isHit(): bool {
// TODO: Implement isHit() method.
return $this->isHit;
}
/**
* @inheritDoc
*/
public function set($value) {
$this->value = $value;
return $this;
}
/**
* @inheritDoc
*/
public function expiresAt($expiration) {
return $this;
}
/**
* @inheritDoc
*/
public function expiresAfter($time) {
return $this;
}
}
@@ -0,0 +1,111 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Psr\Cache\CacheItemInterface;
use MailPoetVendor\Psr\Cache\CacheItemPoolInterface;
class PSRMetadataCache implements CacheItemPoolInterface {
/** @var MetadataCache */
private $metadataCache;
public function __construct(
string $dir,
bool $isReadOnly
) {
$this->metadataCache = new MetadataCache($dir, $isReadOnly);
}
/**
* @inheritDoc
*/
public function getItem($key): CacheItemInterface {
if (!$this->hasItem($key)) {
return new PSRCacheItem($key, false);
}
$item = new PSRCacheItem($key, true);
$item->set($this->metadataCache->fetch($key));
return $item;
}
/**
* @inheritDoc
*/
public function getItems(array $keys = []) {
if (empty($keys)) {
return [];
}
$foundItems = [];
// no internal array function supports this sort of mapping: needs to be iterative
// this filters and combines keys in one pass
foreach ($keys as $key) {
if (!is_string($key)) {
throw new PSRCacheInvalidArgumentException('Invalid key');
}
$foundItems[$key] = $this->getItem($key);
}
return $foundItems;
}
/**
* @inheritDoc
*/
public function hasItem($key): bool {
return $this->metadataCache->contains($key);
}
/**
* @inheritDoc
*/
public function clear(): bool {
return $this->metadataCache->flushAll();
}
/**
* @inheritDoc
*/
public function deleteItem($key): bool {
return $this->metadataCache->delete($key);
}
/**
* @inheritDoc
*/
public function deleteItems(array $keys): bool {
if (empty($keys)) {
return true;
}
foreach ($keys as $key) {
$this->deleteItem($key);
}
return true;
}
/**
* @inheritDoc
*/
public function save(CacheItemInterface $item) {
try {
return $this->metadataCache->save($item->getKey(), $item->get());
} catch (\RuntimeException $e) {
throw new PSRCacheInvalidArgumentException($e->getMessage(), $e->getCode(), $e);
}
}
/**
* @inheritDoc
*/
public function saveDeferred(CacheItemInterface $item) {
return $this->save($item);
}
/**
* @inheritDoc
*/
public function commit(): bool {
return true;
}
}
@@ -0,0 +1,29 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Doctrine\Persistence\Mapping\ProxyClassNameResolver as IProxyClassNameResolver;
/**
* This is exact copy of an anonymous class from \MailPoetVendor\Doctrine\Persistence\Mapping\AbstractClassMetadataFactory
* We need to use a non-anonymous class so that it is serializable within integration tests
* @see https://github.com/doctrine/persistence/blob/2.2.x/lib/Doctrine/Persistence/Mapping/AbstractClassMetadataFactory.php#L516-L536
*/
class ProxyClassNameResolver implements IProxyClassNameResolver {
/**
* @template T
* @return class-string<T>
*/
public function resolveClassName(string $className): string {
$pos = \strrpos($className, '\\' . \MailPoetVendor\Doctrine\Persistence\Proxy::MARKER . '\\');
if ($pos === \false) {
/** @var class-string<T> */
return $className;
}
/** @var class-string<T> */
return \substr($className, $pos + \MailPoetVendor\Doctrine\Persistence\Proxy::MARKER_LENGTH + 2);
}
}
@@ -0,0 +1,181 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Doctrine\Common\Collections\Collection;
use MailPoetVendor\Doctrine\Common\Collections\Criteria;
use MailPoetVendor\Doctrine\ORM\EntityManager;
use MailPoetVendor\Doctrine\ORM\EntityRepository as DoctrineEntityRepository;
use MailPoetVendor\Doctrine\ORM\Mapping\ClassMetadata;
/**
* @template T of object
*/
abstract class Repository {
/** @var EntityManager */
protected $entityManager;
/** @var ClassMetadata<object> */
protected $classMetadata;
/** @var DoctrineEntityRepository<T> */
protected $doctrineRepository;
/** @var string[] */
protected $ignoreColumnsForUpdate = [
'created_at',
];
public function __construct(
EntityManager $entityManager
) {
$this->entityManager = $entityManager;
$this->classMetadata = $entityManager->getClassMetadata($this->getEntityClassName());
$this->doctrineRepository = new DoctrineEntityRepository($this->entityManager, $this->classMetadata);
}
/**
* @param array $criteria
* @param array|null $orderBy
* @param int|null $limit
* @param int|null $offset
* @return T[]
*/
public function findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) {
return $this->doctrineRepository->findBy($criteria, $orderBy, $limit, $offset);
}
/**
* @param Criteria $criteria
* @return Collection<int, T>
*/
public function matching(Criteria $criteria) {
return $this->doctrineRepository->matching($criteria);
}
public function countBy(array $criteria): int {
return $this->doctrineRepository->count($criteria);
}
/**
* @param array $criteria
* @param array|null $orderBy
* @return T|null
*/
public function findOneBy(array $criteria, array $orderBy = null) {
return $this->doctrineRepository->findOneBy($criteria, $orderBy);
}
/**
* @param mixed $id
* @return T|null
*/
public function findOneById($id) {
return $this->doctrineRepository->find($id);
}
/**
* @return T[]
*/
public function findAll() {
return $this->doctrineRepository->findAll();
}
/**
* @param T $entity
*/
public function persist($entity) {
$this->entityManager->persist($entity);
}
public function truncate() {
$tableName = $this->classMetadata->getTableName();
$connection = $this->entityManager->getConnection();
$connection->executeQuery('SET FOREIGN_KEY_CHECKS=0');
$q = "TRUNCATE $tableName";
$connection->executeStatement($q);
$connection->executeQuery('SET FOREIGN_KEY_CHECKS=1');
}
/**
* @param T $entity
*/
public function remove($entity) {
$this->entityManager->remove($entity);
}
/**
* @param T $entity
*/
public function refresh($entity) {
$this->entityManager->refresh($entity);
}
/**
* @param callable(T): bool|null $filter
*/
public function refreshAll(callable $filter = null): void {
$entities = $this->getAllFromIdentityMap();
foreach ($entities as $entity) {
if ($filter && !$filter($entity)) {
continue;
}
$this->entityManager->refresh($entity);
}
}
public function flush() {
$this->entityManager->flush();
}
public function getReference($id) {
return $this->entityManager->getReference($this->getEntityClassName(), $id);
}
/**
* @param T $entity
*/
public function detach($entity) {
$this->entityManager->detach($entity);
}
/**
* @param callable(T): bool|null $filter
*/
public function detachAll(callable $filter = null): void {
$entities = $this->getAllFromIdentityMap();
foreach ($entities as $entity) {
if ($filter && !$filter($entity)) {
continue;
}
$this->entityManager->detach($entity);
}
}
/** @return T[] */
public function getAllFromIdentityMap(): array {
$className = $this->getEntityClassName();
$rootClassName = $this->entityManager->getClassMetadata($className)->rootEntityName;
$entities = $this->entityManager->getUnitOfWork()->getIdentityMap()[$rootClassName] ?? [];
$result = [];
foreach ($entities as $entity) {
if ($entity instanceof $className) {
$result[] = $entity;
}
}
return $result;
}
public function getTableName(): string {
return $this->classMetadata->getTableName();
}
/**
* @return class-string<T>
*/
abstract protected function getEntityClassName();
}
@@ -0,0 +1,91 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine;
if (!defined('ABSPATH')) exit;
use MailPoet\Config\Env;
use MailPoetVendor\Doctrine\ORM\Mapping\ClassMetadata;
use MailPoetVendor\Doctrine\ORM\Mapping\ClassMetadataFactory;
use MailPoetVendor\Doctrine\ORM\Mapping\ClassMetadataInfo;
// Taken from Doctrine docs (see link bellow) but implemented in metadata factory instead of an event
// because we need to add prefix at runtime, not at metadata dump (which is included in builds).
// @see https://www.doctrine-project.org/projects/doctrine-orm/en/2.5/cookbook/sql-table-prefixes.html
class TablePrefixMetadataFactory extends ClassMetadataFactory {
// WordPress tables that are used by MailPoet via Doctrine
const WP_TABLES = [
'posts',
];
/** @var string */
private $prefix;
/** @var string */
private $wpDbPrefix;
/** @var array */
private $prefixedMap = [];
public function __construct() {
if (Env::$dbPrefix === null) {
throw new \RuntimeException('DB table prefix not initialized');
}
$this->prefix = Env::$dbPrefix;
$this->wpDbPrefix = Env::$wpDbPrefix;
$this->setProxyClassNameResolver(new ProxyClassNameResolver());
}
/**
* @return ClassMetadata<object>
*/
public function getMetadataFor($className) {
$classMetadata = parent::getMetadataFor($className);
if (isset($this->prefixedMap[$classMetadata->getName()])) {
return $classMetadata;
}
// prefix tables only after they are saved to cache so the prefix does not get included in cache
// (getMetadataFor can call itself recursively but it saves to cache only after the recursive calls)
$cacheKey = $this->getCacheKey($classMetadata->getName());
$isCached = ($cache = $this->getCache()) ? $cache->hasItem($cacheKey) : false;
if ($classMetadata instanceof ClassMetadata && $isCached) {
$this->addPrefix($classMetadata);
$this->prefixedMap[$classMetadata->getName()] = true;
}
return $classMetadata;
}
/**
* @param ClassMetadata<object> $classMetadata
*/
public function addPrefix(ClassMetadata $classMetadata) {
if (!$classMetadata->isInheritanceTypeSingleTable() || $classMetadata->getName() === $classMetadata->rootEntityName) {
$classMetadata->setPrimaryTable([
'name' => $this->createPrefixedName($classMetadata->getTableName()),
]);
}
foreach ($classMetadata->getAssociationMappings() as $fieldName => $mapping) {
if ($mapping['type'] === ClassMetadataInfo::MANY_TO_MANY && $mapping['isOwningSide']) {
/** @var string $mappedTableName */
$mappedTableName = $mapping['joinTable']['name'];
$classMetadata->associationMappings[$fieldName]['joinTable']['name'] = $this->createPrefixedName($mappedTableName);
}
}
}
/**
* MailPoet tables are prefixed by WP prefix + plugin prefix.
* For entities for WP tables we use WP prefix only.
*/
private function createPrefixedName(string $tableName): string {
// Use WP prefix for WP tables
if (in_array($tableName, self::WP_TABLES, true)) {
return $this->wpDbPrefix . $tableName;
}
// Use WP + plugin prefix for MailPoet tables
return $this->prefix . $tableName;
}
}
@@ -0,0 +1,28 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine\Types;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Doctrine\DBAL\ParameterType;
use MailPoetVendor\Doctrine\DBAL\Platforms\AbstractPlatform;
use MailPoetVendor\Doctrine\DBAL\Types\BigIntType as DoctrineBigIntType;
class BigIntType extends DoctrineBigIntType {
// override Doctrine's bigint type that historically maps DB's "bigint" to PHP's "string"
// (we want to map DB's "bigint" to PHP's "int" in today's 64-bit world)
const NAME = 'bigint';
public function getBindingType() {
return ParameterType::INTEGER;
}
public function convertToPHPValue($value, AbstractPlatform $platform) {
return $value === null ? null : (int)$value;
}
public function getName() {
return self::NAME;
}
}
@@ -0,0 +1,32 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine\Types;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\DBAL\Platforms\AbstractPlatform;
use MailPoetVendor\Doctrine\DBAL\Types\DateTimeTzType;
class DateTimeTzToStringType extends DateTimeTzType {
const NAME = 'datetimetz_to_string';
public function convertToPHPValue($value, AbstractPlatform $platform) {
$dateTime = parent::convertToPHPValue($value, $platform);
if (!$dateTime) {
return $dateTime;
}
return Carbon::instance($dateTime);
}
public function requiresSQLCommentHint(AbstractPlatform $platform): bool {
return true;
}
public function getName(): string {
return self::NAME;
}
}
@@ -0,0 +1,31 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine\Types;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Doctrine\DBAL\Platforms\AbstractPlatform;
class JsonOrSerializedType extends JsonType {
const NAME = 'json_or_serialized';
public function convertToPHPValue($value, AbstractPlatform $platform) {
if ($value === null) {
return null;
}
if (is_resource($value)) {
$value = stream_get_contents($value);
}
if (is_serialized($value)) {
return unserialize($value);
}
return parent::convertToPHPValue($value, $platform);
}
public function getName() {
return self::NAME;
}
}
@@ -0,0 +1,62 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine\Types;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Doctrine\DBAL\Platforms\AbstractPlatform;
use MailPoetVendor\Doctrine\DBAL\Types\Type;
class JsonType extends Type {
const NAME = 'json';
public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform) {
return $platform->getJsonTypeDeclarationSQL($fieldDeclaration);
}
public function convertToDatabaseValue($value, AbstractPlatform $platform) {
if ($value === null) {
return null;
}
$flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
if (defined('JSON_PRESERVE_ZERO_FRACTION')) {
$flags |= JSON_PRESERVE_ZERO_FRACTION; // phpcs:ignore
}
$encoded = json_encode($value, $flags);
$this->handleErrors();
return $encoded;
}
public function convertToPHPValue($value, AbstractPlatform $platform) {
if ($value === null || $value === '') {
return null;
}
if (is_resource($value)) {
$value = stream_get_contents($value);
}
$value = mb_convert_encoding((string)$value, 'UTF-8', 'UTF-8'); // sanitize invalid utf8
$decoded = json_decode($value, true);
$this->handleErrors();
return $decoded;
}
public function getName() {
return self::NAME;
}
public function requiresSQLCommentHint(AbstractPlatform $platform) {
return !$platform->hasNativeJsonType();
}
private function handleErrors() {
$error = json_last_error();
if ($error !== JSON_ERROR_NONE) {
throw new \RuntimeException('Error when parsing JSON database value: "' . json_last_error_msg() . '"', $error);
}
}
}
@@ -0,0 +1,41 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine\Types;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Doctrine\DBAL\Platforms\AbstractPlatform;
use MailPoetVendor\Doctrine\DBAL\Types\Type;
class SerializedArrayType extends Type {
const NAME = 'serialized_array';
public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform) {
return $platform->getClobTypeDeclarationSQL($fieldDeclaration);
}
public function convertToDatabaseValue($value, AbstractPlatform $platform) {
return \serialize($value);
}
public function convertToPHPValue($value, AbstractPlatform $platform) {
if ($value === null) {
return null;
}
$value = \is_resource($value) ? \stream_get_contents($value) : $value;
$val = \unserialize($value);
if ($val === \false && $value !== 'b:0;') {
return null;
}
return $val;
}
public function getName() {
return self::NAME;
}
public function requiresSQLCommentHint(AbstractPlatform $platform) {
return true;
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,17 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine\Validator;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Symfony\Contracts\Translation\TranslatorTrait;
class Translator implements \MailPoetVendor\Symfony\Contracts\Translation\TranslatorInterface {
use TranslatorTrait;
public function transChoice($id, $number, array $parameters = [], $domain = null, $locale = null) {
return $this->trans($id, ['%count%' => $number] + $parameters, $domain, $locale);
}
}
@@ -0,0 +1,54 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine\Validator;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Symfony\Component\Validator\ConstraintViolationInterface;
use MailPoetVendor\Symfony\Component\Validator\ConstraintViolationListInterface;
class ValidationException extends \RuntimeException {
/** @var string */
private $resourceName;
/** @var ConstraintViolationListInterface|ConstraintViolationInterface[] */
private $violations;
public function __construct(
$resourceName,
ConstraintViolationListInterface $violations
) {
$this->resourceName = $resourceName;
$this->violations = $violations;
$linePrefix = ' ';
$message = "Validation failed for '$resourceName'.\nDetails:\n";
$message .= $linePrefix . implode("\n$linePrefix", $this->getErrors());
parent::__construct($message);
}
/** @return string */
public function getResourceName() {
return $this->resourceName;
}
/** @return ConstraintViolationListInterface|ConstraintViolationInterface[] */
public function getViolations() {
return $this->violations;
}
/** @return string[] */
public function getErrors() {
$messages = [];
foreach ($this->violations as $violation) {
$messages[] = $this->formatError($violation);
}
sort($messages);
return $messages;
}
private function formatError(ConstraintViolationInterface $violation) {
return '[' . $violation->getPropertyPath() . '] ' . $violation->getMessage();
}
}
@@ -0,0 +1,46 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Doctrine\Validator;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\Annotations\AnnotationReaderProvider;
use MailPoet\Doctrine\PSRMetadataCache;
use MailPoetVendor\Symfony\Component\Validator\Validation;
class ValidatorFactory {
const METADATA_DIR = __DIR__ . '/../../../generated/validator-metadata';
/** @var AnnotationReaderProvider */
private $annotationReaderProvider;
public function __construct(
AnnotationReaderProvider $annotationReaderProvider
) {
$this->annotationReaderProvider = $annotationReaderProvider;
}
public function createValidator() {
$builder = Validation::createValidatorBuilder();
// we need to use our own translator here.
// If we let the default translator to be used in the builder it uses an anonymous class and that is a problem
// All integration tests would fail with: [Exception] Serialization of 'class@anonymous' is not allowed
$translator = new Translator();
$translator->setLocale('en');
$builder->setTranslator($translator);
// annotation reader exists only in dev environment, on production cache is pre-generated
$annotationReader = $this->annotationReaderProvider->getAnnotationReader();
if ($annotationReader) {
$builder->setDoctrineAnnotationReader($annotationReader)
->enableAnnotationMapping(true);
}
// metadata cache (for production cache is pre-generated at build time)
$isReadOnly = !$annotationReader;
$builder->setMappingCache(new PSRMetadataCache(self::METADATA_DIR, $isReadOnly));
return $builder->getValidator();
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,139 @@
<?php declare(strict_types = 1);
namespace MailPoet\Doctrine\WPDB;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\WPDB\Exceptions\ConnectionException;
use MailPoet\Doctrine\WPDB\Exceptions\QueryException;
use MailPoetVendor\Doctrine\DBAL\Driver\ServerInfoAwareConnection;
use MailPoetVendor\Doctrine\DBAL\ParameterType;
use mysqli;
use PDO;
use PDOException;
use Throwable;
use wpdb;
/**
* @phpcs:disable Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
*/
class Connection implements ServerInfoAwareConnection {
public function __construct() {
global $wpdb;
if (!$wpdb instanceof wpdb) {
throw new ConnectionException('WPDB is not initialized.');
}
}
public function prepare(string $sql): Statement {
return new Statement($this, $sql);
}
public function query(string $sql): Result {
global $wpdb;
$value = $this->runQuery($sql);
$result = $wpdb->last_result;
return new Result($result, is_int($value) ? $value : 0);
}
public function exec(string $sql): int {
global $wpdb;
$this->runQuery($sql);
return $wpdb->rows_affected;
}
public function beginTransaction(): bool {
$this->runQuery('START TRANSACTION');
return true;
}
public function commit(): bool {
$this->runQuery('COMMIT');
return true;
}
public function rollBack(): bool {
$this->runQuery('ROLLBACK');
return true;
}
/**
* Quotes a string for use in a query.
* The type hint parameter is not needed for WPDB (mysqli).
* See also Doctrine\DBAL\Driver\Mysqli\Connection::quote().
*
* @param mixed $value
* @param int $type
*/
public function quote($value, $type = ParameterType::STRING): string {
global $wpdb;
return "'" . $wpdb->_escape($value) . "'";
}
/**
* @param string|null $name
*/
public function lastInsertId($name = null): int {
global $wpdb;
return $wpdb->insert_id;
}
public function getServerVersion(): string {
global $wpdb;
return $wpdb->db_server_info();
}
/**
* MySQL — returns an instance of mysqli.
* SQLite — returns an instance of PDO.
*
* @return mysqli|PDO|false|null
*/
public function getNativeConnection() {
global $wpdb;
// WPDB keeps connection instance (mysqli) in a protected property $dbh.
// We can access it using a closure that is bound to the $wpdb instance.
$getDbh = function () {
return $this->dbh; // @phpstan-ignore-line -- PHPStan doesn't know the binding context
};
$dbh = $getDbh->call($wpdb);
if (is_object($dbh) && method_exists($dbh, 'get_pdo')) {
return $dbh->get_pdo();
}
return $getDbh->call($wpdb);
}
public static function isSQLite(): bool {
return defined('DB_ENGINE') && DB_ENGINE === 'sqlite';
}
private function runQuery(string $sql) {
global $wpdb;
try {
$value = $wpdb->query($sql); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
} catch (Throwable $e) {
if ($e instanceof PDOException) {
throw new QueryException($e->getMessage(), $e->errorInfo[0] ?? null, $e->errorInfo[1] ?? 0);
}
throw new QueryException($e->getMessage(), null, 0, $e);
}
if ($value === false) {
$this->handleQueryError();
}
return $value;
}
private function handleQueryError(): void {
global $wpdb;
$nativeConnection = $this->getNativeConnection();
if ($nativeConnection instanceof mysqli) {
throw new QueryException($wpdb->last_error, $nativeConnection->sqlstate, $nativeConnection->errno);
} elseif ($nativeConnection instanceof PDO) {
$info = $nativeConnection->errorInfo();
throw new QueryException($wpdb->last_error, $info[0] ?? null, $info[1] ?? 0);
}
throw new QueryException($wpdb->last_error);
}
}
@@ -0,0 +1,87 @@
<?php declare (strict_types = 1);
namespace MailPoet\Doctrine\WPDB;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\WPDB\Exceptions\MissingParameterException;
use MailPoetVendor\Doctrine\DBAL\ParameterType;
use MailPoetVendor\Doctrine\DBAL\SQL\Parser\Visitor;
class ConvertParameters implements Visitor {
private const PARAM_TYPE_MAP = [
ParameterType::STRING => '%s',
ParameterType::INTEGER => '%d',
ParameterType::ASCII => '%s',
ParameterType::BINARY => '%s',
ParameterType::BOOLEAN => '%d',
ParameterType::NULL => '%s',
ParameterType::LARGE_OBJECT => '%s',
];
/** @var list<string> */
private array $buffer = [];
/** @var array<array-key, array{0: string, 1: mixed, 2: int}> */
private array $params;
private array $values = [];
private int $cursor = 1;
public function __construct(
array $params
) {
$this->params = $params;
}
public function acceptPositionalParameter(string $sql): void {
$position = $this->cursor++;
$this->acceptParameter($position);
}
public function acceptNamedParameter(string $sql): void {
$this->acceptParameter(trim($sql, ':'));
}
public function acceptOther(string $sql): void {
$this->buffer[] = $sql;
}
public function getSQL(): string {
return implode('', $this->buffer);
}
public function getValues(): array {
return $this->values;
}
/** @param array-key $key */
private function acceptParameter($key): void {
if (!array_key_exists($key, $this->params)) {
throw new MissingParameterException(sprintf("Parameter '%s' was defined in the query, but not provided.", $key));
}
[, $value, $type] = $this->params[$key];
// WPDB doesn't support NULL values. We need to handle them explicitly.
if ($value === null) {
$this->buffer[] = 'NULL';
return;
}
// WPDB doesn't accept non-scalar values. We need to cast them (PDO-like behavior).
if (!is_scalar($value)) {
if ($type === ParameterType::INTEGER) {
$value = (int)$value; // @phpstan-ignore-line -- cast may fail and that's OK
} elseif ($type === ParameterType::BOOLEAN) {
$value = (bool)$value;
} else {
$value = (string)$value; // @phpstan-ignore-line -- cast may fail and that's OK
}
}
$this->values[] = $value;
$this->buffer[] = self::PARAM_TYPE_MAP[$type] ?? '%s';
}
}
@@ -0,0 +1,14 @@
<?php declare(strict_types = 1);
namespace MailPoet\Doctrine\WPDB;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Doctrine\DBAL\Driver\AbstractMySQLDriver;
class Driver extends AbstractMySQLDriver {
public function connect(array $params): Connection {
return new Connection();
}
}
@@ -0,0 +1,11 @@
<?php declare(strict_types = 1);
namespace MailPoet\Doctrine\WPDB\Exceptions;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Doctrine\DBAL\Driver\AbstractException;
class ConnectionException extends AbstractException {
}
@@ -0,0 +1,11 @@
<?php declare(strict_types = 1);
namespace MailPoet\Doctrine\WPDB\Exceptions;
if (!defined('ABSPATH')) exit;
use Exception;
class MissingParameterException extends Exception {
}
@@ -0,0 +1,11 @@
<?php declare(strict_types = 1);
namespace MailPoet\Doctrine\WPDB\Exceptions;
if (!defined('ABSPATH')) exit;
use Exception;
class NotSupportedException extends Exception {
}
@@ -0,0 +1,11 @@
<?php declare(strict_types = 1);
namespace MailPoet\Doctrine\WPDB\Exceptions;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Doctrine\DBAL\Driver\AbstractException;
class QueryException extends AbstractException {
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,75 @@
<?php declare(strict_types = 1);
namespace MailPoet\Doctrine\WPDB;
if (!defined('ABSPATH')) exit;
use MailPoetVendor\Doctrine\DBAL\Driver\Result as ResultInterface;
/**
* WPDB fetches all results from the underlying database driver,
* so we need to implement the result methods on in-memory data.
*/
class Result implements ResultInterface {
/** @var array[] */
private array $result = [];
private int $rowCount;
private int $cursor = 0;
public function __construct(
array $result,
int $rowCount
) {
foreach ($result as $row) {
$this->result[] = (array)$row;
}
$this->rowCount = $rowCount;
}
public function fetchNumeric() {
$row = $this->result[$this->cursor++] ?? null;
return $row === null ? false : array_values($row);
}
public function fetchAssociative() {
return $this->result[$this->cursor++] ?? false;
}
public function fetchOne() {
$row = $this->result[$this->cursor++] ?? null;
return $row === null ? false : reset($row);
}
public function fetchAllNumeric(): array {
$result = [];
foreach ($this->result as $row) {
$result[] = array_values($row);
}
return $result;
}
public function fetchAllAssociative(): array {
return $this->result;
}
public function fetchFirstColumn(): array {
$result = [];
foreach ($this->result as $row) {
$result[] = reset($row);
}
return $result;
}
public function rowCount(): int {
return $this->rowCount;
}
public function columnCount(): int {
return count($this->result[0] ?? []);
}
public function free(): void {
$this->cursor = 0;
}
}
@@ -0,0 +1,73 @@
<?php declare(strict_types = 1);
namespace MailPoet\Doctrine\WPDB;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\WPDB\Exceptions\NotSupportedException;
use MailPoetVendor\Doctrine\DBAL\Driver\Result;
use MailPoetVendor\Doctrine\DBAL\Driver\Statement as StatementInterface;
use MailPoetVendor\Doctrine\DBAL\ParameterType;
use MailPoetVendor\Doctrine\DBAL\SQL\Parser;
class Statement implements StatementInterface {
private Connection $connection;
private Parser $parser;
private string $sql;
private array $params = [];
public function __construct(
Connection $connection,
string $sql
) {
$this->connection = $connection;
$this->parser = new Parser(false);
$this->sql = $sql;
}
/**
* @param string|int $param
* @param mixed $value
* @param int $type
* @return true
*/
public function bindValue($param, $value, $type = ParameterType::STRING) {
$this->params[$param] = [$param, $value, $type];
return true;
}
/**
* @param string|int $param
* @param mixed $variable
* @param int $type
* @param int|null $length
* @return true
*/
public function bindParam($param, &$variable, $type = ParameterType::STRING, $length = null) {
throw new NotSupportedException(
'Statement::bindParam() is deprecated in Doctrine and not implemented in WPDB driver. Use Statement::bindValue() instead.'
);
}
public function execute($params = null): Result {
if ($params !== null) {
throw new NotSupportedException(
'Statement::execute() with parameters is deprecated in Doctrine and not implemented in WPDB driver. Use Statement::bindValue() instead.'
);
}
// Convert '?' parameters to WPDB format (sprintf-like: '%s', '%d', ...),
// and add support for named parameters that are not supported by mysqli.
$visitor = new ConvertParameters($this->params);
$this->parser->parse($this->sql, $visitor);
$sql = $visitor->getSQL();
$values = $visitor->getValues();
global $wpdb;
$query = count($values) > 0
? $wpdb->prepare($sql, $values) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
: $sql;
return $this->connection->query($query);
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1 @@
<?php