JezK
Edit File: CustomFieldsRepository.php
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing namespace MailPoet\CustomFields; if (!defined('ABSPATH')) exit; use MailPoet\Doctrine\Repository; use MailPoet\Entities\CustomFieldEntity; use MailPoet\Entities\DynamicSegmentFilterEntity; use MailPoet\Entities\FormEntity; use MailPoet\Entities\SegmentEntity; use MailPoet\Entities\SubscriberCustomFieldEntity; use MailPoet\Segments\DynamicSegments\Filters\MailPoetCustomFields; use MailPoetVendor\Doctrine\DBAL\ArrayParameterType; use MailPoetVendor\Doctrine\ORM\EntityManager; /** * @extends Repository<CustomFieldEntity> */ class CustomFieldsRepository extends Repository { public function __construct( EntityManager $entityManager ) { parent::__construct($entityManager); } protected function getEntityClassName() { return CustomFieldEntity::class; } /** * @param array $data * @return CustomFieldEntity * * Updates the entity in place and does not touch `deletedAt`. Callers that * accept ids from outside (REST endpoints, etc.) must reject trashed fields * before calling this — otherwise the field stays trashed despite the update. */ public function createOrUpdate($data) { // set name as label by default if (empty($data['params']['label']) && isset($data['name'])) { $data['params']['label'] = $data['name']; } if (isset($data['id'])) { $field = $this->findOneById((int)$data['id']); } elseif (isset($data['name'])) { $field = $this->findOneBy(['name' => $data['name']]); } if (!isset($field)) { $field = new CustomFieldEntity(); $this->entityManager->persist($field); } if (isset($data['name'])) $field->setName($data['name']); if (isset($data['type'])) $field->setType($data['type']); if (isset($data['params'])) $field->setParams($data['params']); $this->entityManager->flush(); return $field; } public function findAllAsArray() { $customFieldsTable = $this->entityManager->getClassMetadata(CustomFieldEntity::class)->getTableName(); $query = $this->entityManager ->getConnection() ->createQueryBuilder() ->select('*') ->from($customFieldsTable) ->where('deleted_at IS NULL') ->execute(); return $query->fetchAllAssociative(); } /** * @return CustomFieldEntity[] */ public function findAllActive(): array { return $this->findBy(['deletedAt' => null], ['createdAt' => 'asc']); } public function deleteCustomField(CustomFieldEntity $customField): void { $this->bulkTrash([(int)$customField->getId()]); } public function hasSubscriberValues(int $customFieldId): bool { $count = (int)$this->entityManager->createQueryBuilder() ->select('COUNT(scf.id)') ->from(SubscriberCustomFieldEntity::class, 'scf') ->where('scf.customField = :id') ->setParameter('id', $customFieldId) ->setMaxResults(1) ->getQuery()->getSingleScalarResult(); return $count > 0; } /** * @param array<int|null> $ids */ public function bulkTrash(array $ids): int { $ids = $this->normalizeIds($ids); if (!$ids) { return 0; } $result = $this->entityManager->createQueryBuilder() ->update(CustomFieldEntity::class, 'cf') ->set('cf.deletedAt', 'CURRENT_TIMESTAMP()') ->where('cf.id IN (:ids)') ->andWhere('cf.deletedAt IS NULL') ->setParameter('ids', $ids) ->getQuery()->execute(); $this->refreshAll(function (CustomFieldEntity $entity) use ($ids) { return in_array($entity->getId(), $ids, true); }); return $result; } /** * @param array<int|null> $ids */ public function bulkRestore(array $ids): int { $ids = $this->normalizeIds($ids); if (!$ids) { return 0; } $result = $this->entityManager->createQueryBuilder() ->update(CustomFieldEntity::class, 'cf') ->set('cf.deletedAt', ':deletedAt') ->where('cf.id IN (:ids)') ->andWhere('cf.deletedAt IS NOT NULL') ->setParameter('deletedAt', null) ->setParameter('ids', $ids) ->getQuery()->execute(); $this->refreshAll(function (CustomFieldEntity $entity) use ($ids) { return in_array($entity->getId(), $ids, true); }); return $result; } /** * Permanently deletes trashed custom fields and removes references from * subscriber values, forms, and dynamic segments. * * @param array<int|null> $ids * @return int Number of custom fields deleted. */ public function bulkDelete(array $ids): int { $ids = $this->normalizeIds($ids); if (!$ids) { return 0; } $ids = $this->findTrashedIds($ids); return $this->deleteTrashedByIds($ids); } public function emptyTrash(): int { return $this->deleteTrashedByIds($this->findTrashedIds()); } /** * @param int[] $ids */ private function deleteTrashedByIds(array $ids): int { if (!$ids) { return 0; } $deleted = 0; $this->entityManager->transactional(function (EntityManager $entityManager) use ($ids, &$deleted): void { $this->removeCustomFieldsFromForms($ids); $this->removeCustomFieldsFromDynamicSegments($ids); $subscriberCustomFieldTable = $entityManager->getClassMetadata(SubscriberCustomFieldEntity::class)->getTableName(); $entityManager->getConnection()->executeStatement( "DELETE FROM $subscriberCustomFieldTable WHERE custom_field_id IN (:ids)", ['ids' => $ids], ['ids' => ArrayParameterType::INTEGER] ); $customFieldsTable = $entityManager->getClassMetadata(CustomFieldEntity::class)->getTableName(); $deleted = (int)$entityManager->getConnection()->executeStatement( "DELETE FROM $customFieldsTable WHERE id IN (:ids)", ['ids' => $ids], ['ids' => ArrayParameterType::INTEGER] ); }); $this->entityManager->clear(CustomFieldEntity::class); $this->entityManager->clear(SubscriberCustomFieldEntity::class); $this->entityManager->clear(FormEntity::class); $this->entityManager->clear(DynamicSegmentFilterEntity::class); return $deleted; } /** * Listing with subscriber counts + search + type filter + group + sort + * pagination, all done in SQL. `forms_count` and `dynamic_segments_count` are * derived from JSON (form body blocks, segment filter data) and cannot be * expressed in SQL, so they are computed in PHP for the current page only and * are display-only — they are intentionally not filterable or sortable. * * @param array{search?: string, orderby?: string, order?: string, page?: int, per_page?: int, group?: string, filter?: array{type?: string[]}} $args * @return array{items: array<int, array{id: int, name: string, label: string, type: string, params: array, required: bool, subscribers_count: int, forms_count: int, dynamic_segments_count: int, created_at: ?\DateTimeInterface, updated_at: ?\DateTimeInterface, deleted_at: ?\DateTimeInterface}>, total: int, groups: array<int, array{name: string, label: string, count: int}>} */ public function listWithCounts(array $args = []): array { $search = isset($args['search']) ? trim((string)$args['search']) : ''; $orderby = isset($args['orderby']) && is_string($args['orderby']) ? $args['orderby'] : 'name'; $order = isset($args['order']) && strtolower((string)$args['order']) === 'desc' ? 'DESC' : 'ASC'; $page = isset($args['page']) ? max(1, (int)$args['page']) : 1; $perPage = isset($args['per_page']) ? max(1, min(100, (int)$args['per_page'])) : 25; $group = isset($args['group']) && $args['group'] === 'trash' ? 'trash' : 'all'; $filter = isset($args['filter']) && is_array($args['filter']) ? $args['filter'] : []; $types = isset($filter['type']) && is_array($filter['type']) ? array_values(array_filter(array_map('strval', $filter['type']))) : []; $sortable = [ 'name' => 'cf.name', 'type' => 'cf.type', 'created_at' => 'cf.createdAt', 'subscribers_count' => 'subscribersCount', ]; $orderByExpr = $sortable[$orderby] ?? $sortable['name']; $qb = $this->entityManager->createQueryBuilder() ->select('cf.id AS id, cf.name AS name, cf.type AS type, cf.params AS params, cf.createdAt AS created_at, cf.updatedAt AS updated_at, cf.deletedAt AS deleted_at, COUNT(DISTINCT s.id) AS subscribersCount') ->from(CustomFieldEntity::class, 'cf') ->leftJoin(SubscriberCustomFieldEntity::class, 'scf', 'WITH', 'scf.customField = cf') ->leftJoin('scf.subscriber', 's', 'WITH', 's.deletedAt IS NULL') ->groupBy('cf.id') ->orderBy($orderByExpr, $order); // Deterministic secondary ordering so paginated results are stable on ties. if ($orderby !== 'name') { $qb->addOrderBy('cf.name', 'ASC'); } $qb->addOrderBy('cf.id', 'ASC') ->setFirstResult(($page - 1) * $perPage) ->setMaxResults($perPage); $this->applyListFilters($qb, $search, $types, $group); /** @var array<array{id: int, name: string, type: string, params: mixed, created_at: mixed, updated_at: mixed, deleted_at: mixed, subscribersCount: int|string}> $rows */ $rows = $qb->getQuery()->getArrayResult(); $groups = $this->getGroups(); $total = $this->countWithFilters($search, $types, $group); $customFieldIds = array_map('intval', array_column($rows, 'id')); $formsCounts = $this->getFormCountsByCustomFieldIds($customFieldIds); $dynamicSegmentsCounts = $this->getDynamicSegmentCountsByCustomFieldIds($customFieldIds); $items = []; foreach ($rows as $row) { $id = (int)$row['id']; $params = is_array($row['params']) ? $row['params'] : []; $label = isset($params['label']) && is_scalar($params['label']) ? (string)$params['label'] : (string)$row['name']; $createdAt = $row['created_at'] ?? null; $updatedAt = $row['updated_at'] ?? null; $deletedAt = $row['deleted_at'] ?? null; $items[] = [ 'id' => $id, 'name' => (string)$row['name'], 'label' => $label, 'type' => (string)$row['type'], 'params' => $params, 'required' => (bool)($params['required'] ?? false), 'subscribers_count' => (int)$row['subscribersCount'], 'forms_count' => $formsCounts[$id] ?? 0, 'dynamic_segments_count' => $dynamicSegmentsCounts[$id] ?? 0, 'created_at' => $createdAt instanceof \DateTimeInterface ? $createdAt : null, 'updated_at' => $updatedAt instanceof \DateTimeInterface ? $updatedAt : null, 'deleted_at' => $deletedAt instanceof \DateTimeInterface ? $deletedAt : null, ]; } return ['items' => $items, 'total' => $total, 'groups' => $groups]; } /** * @param string[] $types */ private function applyListFilters(\MailPoetVendor\Doctrine\ORM\QueryBuilder $qb, string $search, array $types, string $group): void { if ($search !== '') { $qb->andWhere('cf.name LIKE :search') ->setParameter('search', '%' . $search . '%'); } if ($types) { $qb->andWhere('cf.type IN (:types)') ->setParameter('types', $types); } $this->applyGroup($qb, $group); } /** * @param string[] $types */ private function countWithFilters(string $search, array $types, string $group): int { $qb = $this->entityManager->createQueryBuilder() ->select('COUNT(cf.id)') ->from(CustomFieldEntity::class, 'cf'); $this->applyListFilters($qb, $search, $types, $group); return (int)$qb->getQuery()->getSingleScalarResult(); } /** * @return array<int, array{name: string, label: string, count: int}> */ private function getGroups(): array { $activeCount = $this->countByDeletedAt(false); $trashedCount = $this->countByDeletedAt(true); return [ [ 'name' => 'all', 'label' => __('All', 'mailpoet'), 'count' => $activeCount, ], [ 'name' => 'trash', 'label' => __('Trash', 'mailpoet'), 'count' => $trashedCount, ], ]; } private function countByDeletedAt(bool $trashed): int { $queryBuilder = $this->entityManager->createQueryBuilder() ->select('COUNT(cf.id)') ->from(CustomFieldEntity::class, 'cf'); $this->applyGroup($queryBuilder, $trashed ? 'trash' : 'all'); return (int)$queryBuilder->getQuery()->getSingleScalarResult(); } private function applyGroup(\MailPoetVendor\Doctrine\ORM\QueryBuilder $queryBuilder, string $group): void { if ($group === 'trash') { $queryBuilder->andWhere('cf.deletedAt IS NOT NULL'); } else { $queryBuilder->andWhere('cf.deletedAt IS NULL'); } } /** * @param array<int|null> $ids * @return int[] */ private function normalizeIds(array $ids): array { return array_values(array_filter(array_map( static function ($id): int { return (int)$id; }, $ids ))); } /** * @param int[]|null $ids * @return int[] */ private function findTrashedIds(?array $ids = null): array { $customFieldsTable = $this->entityManager->getClassMetadata(CustomFieldEntity::class)->getTableName(); $queryBuilder = $this->entityManager->getConnection() ->createQueryBuilder() ->select('id') ->from($customFieldsTable) ->where('deleted_at IS NOT NULL'); if ($ids !== null) { $queryBuilder ->andWhere('id IN (:ids)') ->setParameter('ids', $ids, ArrayParameterType::INTEGER); } $rows = $queryBuilder->executeQuery()->fetchAllAssociative(); $trashedIds = []; foreach ($rows as $row) { $id = $row['id'] ?? null; if (is_int($id) || is_string($id)) { $trashedIds[] = (int)$id; } } return $trashedIds; } /** * @param int[] $customFieldIds * @return array<int, int> */ private function getFormCountsByCustomFieldIds(array $customFieldIds): array { if (!$customFieldIds) { return []; } $customFieldIdsLookup = array_flip($customFieldIds); $counts = array_fill_keys($customFieldIds, 0); /** @var FormEntity[] $forms */ $forms = $this->entityManager->createQueryBuilder() ->select('f') ->from(FormEntity::class, 'f') ->where('f.deletedAt IS NULL') ->getQuery() ->getResult(); foreach ($forms as $form) { $formCustomFieldIds = []; foreach ($form->getBlocksByTypes(FormEntity::FORM_FIELD_TYPES) as $block) { $customFieldId = isset($block['id']) ? (int)$block['id'] : 0; if (isset($customFieldIdsLookup[$customFieldId])) { $formCustomFieldIds[$customFieldId] = true; } } foreach (array_keys($formCustomFieldIds) as $customFieldId) { $counts[$customFieldId]++; } } return $counts; } /** * @param int[] $customFieldIds */ private function removeCustomFieldsFromForms(array $customFieldIds): void { $customFieldIdsLookup = array_flip($customFieldIds); /** @var FormEntity[] $forms */ $forms = $this->entityManager->createQueryBuilder() ->select('f') ->from(FormEntity::class, 'f') ->getQuery() ->getResult(); foreach ($forms as $form) { $body = $form->getBody(); if (!is_array($body)) { continue; } $updatedBody = $this->removeCustomFieldBlocks($body, $customFieldIdsLookup); if ($updatedBody !== $body) { $form->setBody($updatedBody); } } } /** * @param array<mixed, mixed> $blocks * @param array<int, int> $customFieldIdsLookup * @return array<int, mixed> */ private function removeCustomFieldBlocks(array $blocks, array $customFieldIdsLookup): array { $updated = []; foreach ($blocks as $block) { if (!is_array($block)) { $updated[] = $block; continue; } $rawCustomFieldId = $block['id'] ?? null; $customFieldId = is_int($rawCustomFieldId) || is_string($rawCustomFieldId) ? (int)$rawCustomFieldId : 0; $type = isset($block['type']) && is_string($block['type']) ? $block['type'] : null; if ($type !== null && in_array($type, FormEntity::FORM_FIELD_TYPES, true) && isset($customFieldIdsLookup[$customFieldId])) { continue; } if (isset($block['body']) && is_array($block['body'])) { $block['body'] = $this->removeCustomFieldBlocks($block['body'], $customFieldIdsLookup); } $updated[] = $block; } return $updated; } /** * @param int[] $customFieldIds */ private function removeCustomFieldsFromDynamicSegments(array $customFieldIds): void { $customFieldIdsLookup = array_flip($customFieldIds); /** @var DynamicSegmentFilterEntity[] $filters */ $filters = $this->entityManager->createQueryBuilder() ->select('dsf') ->from(DynamicSegmentFilterEntity::class, 'dsf') ->where('dsf.filterData.action = :action') ->setParameter('action', MailPoetCustomFields::TYPE) ->getQuery() ->getResult(); foreach ($filters as $filter) { $customFieldIdParam = $filter->getFilterData()->getParam('custom_field_id'); if (!is_int($customFieldIdParam) && !is_string($customFieldIdParam)) { continue; } if (isset($customFieldIdsLookup[(int)$customFieldIdParam])) { $this->entityManager->remove($filter); } } } /** * @param int[] $customFieldIds * @return array<int, int> */ private function getDynamicSegmentCountsByCustomFieldIds(array $customFieldIds): array { if (!$customFieldIds) { return []; } $customFieldIdsLookup = array_flip($customFieldIds); $segmentIdsByCustomFieldId = array_fill_keys($customFieldIds, []); /** @var DynamicSegmentFilterEntity[] $filters */ $filters = $this->entityManager->createQueryBuilder() ->select('dsf') ->from(DynamicSegmentFilterEntity::class, 'dsf') ->join('dsf.segment', 's') ->where('s.deletedAt IS NULL') ->andWhere('dsf.filterData.action = :action') ->setParameter('action', MailPoetCustomFields::TYPE) ->getQuery() ->getResult(); foreach ($filters as $filter) { $customFieldIdParam = $filter->getFilterData()->getParam('custom_field_id'); if (!is_int($customFieldIdParam) && !is_string($customFieldIdParam)) { continue; } $customFieldId = (int)$customFieldIdParam; if (!isset($customFieldIdsLookup[$customFieldId])) { continue; } $segment = $filter->getSegment(); if (!$segment instanceof SegmentEntity) { continue; } $segmentIdsByCustomFieldId[$customFieldId][(int)$segment->getId()] = true; } $counts = []; foreach ($segmentIdsByCustomFieldId as $customFieldId => $segmentIds) { $counts[$customFieldId] = count($segmentIds); } return $counts; } }