Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field;
use Doctrine\DBAL\Exception\DriverException;
use Doctrine\DBAL\Schema\SchemaException;
use Mautic\LeadBundle\Exception\NoListenerException;
use Mautic\LeadBundle\Field\Dispatcher\FieldColumnBackgroundJobDispatcher;
use Mautic\LeadBundle\Field\Exception\AbortColumnCreateException;
use Mautic\LeadBundle\Field\Exception\AbortColumnUpdateException;
use Mautic\LeadBundle\Field\Exception\ColumnAlreadyCreatedException;
use Mautic\LeadBundle\Field\Exception\CustomFieldLimitException;
use Mautic\LeadBundle\Field\Exception\LeadFieldWasNotFoundException;
use Mautic\LeadBundle\Field\Notification\CustomFieldNotification;
use Mautic\LeadBundle\Model\FieldModel;
class BackgroundService
{
public function __construct(
private FieldModel $fieldModel,
private CustomFieldColumn $customFieldColumn,
private LeadFieldSaver $leadFieldSaver,
private LeadFieldDeleter $leadFieldDeleter,
private FieldColumnBackgroundJobDispatcher $fieldColumnBackgroundJobDispatcher,
private CustomFieldNotification $customFieldNotification,
) {
}
/**
* @throws AbortColumnCreateException
* @throws ColumnAlreadyCreatedException
* @throws CustomFieldLimitException
* @throws LeadFieldWasNotFoundException
* @throws \Doctrine\DBAL\Exception
* @throws DriverException
* @throws SchemaException
* @throws \Mautic\CoreBundle\Exception\SchemaException
*/
public function addColumn(int $leadFieldId, ?int $userId): void
{
$leadField = $this->fieldModel->getEntity($leadFieldId);
if (null === $leadField) {
throw new LeadFieldWasNotFoundException('LeadField entity was not found');
}
if (!$leadField->getColumnIsNotCreated()) {
$this->customFieldNotification->customFieldWasCreated($leadField, $userId);
throw new ColumnAlreadyCreatedException('Column was already created');
}
try {
$this->fieldColumnBackgroundJobDispatcher->dispatchPreAddColumnEvent($leadField);
} catch (NoListenerException $e) {
}
try {
$this->customFieldColumn->processCreateLeadColumn($leadField, false);
} catch (DriverException|SchemaException|\Mautic\CoreBundle\Exception\SchemaException $e) {
$this->customFieldNotification->customFieldCannotBeCreated($leadField, $userId);
throw $e;
} catch (CustomFieldLimitException $e) {
$this->customFieldNotification->customFieldLimitWasHit($leadField, $userId);
throw $e;
}
$leadField->setColumnWasCreated();
$this->leadFieldSaver->saveLeadFieldEntity($leadField, false);
$this->customFieldNotification->customFieldWasCreated($leadField, $userId);
}
/**
* @throws AbortColumnUpdateException
* @throws DriverException
* @throws LeadFieldWasNotFoundException
* @throws SchemaException
* @throws \Mautic\CoreBundle\Exception\SchemaException
*/
public function updateColumn(int $leadFieldId, int $userId): void
{
$leadField = $this->fieldModel->getEntity($leadFieldId);
if (null === $leadField) {
throw new LeadFieldWasNotFoundException('LeadField entity was not found');
}
try {
$this->fieldColumnBackgroundJobDispatcher->dispatchPreUpdateColumnEvent($leadField);
} catch (NoListenerException) {
}
try {
// Update the column length of leads table.
$this->customFieldColumn->processUpdateLeadColumnLength($leadField);
} catch (\Mautic\CoreBundle\Exception\SchemaException|\OutOfRangeException $e) {
$this->customFieldNotification->customFieldCannotBeUpdated($leadField, $userId);
throw $e;
}
$this->customFieldColumn->processUpdateLeadColumn($leadField);
$this->customFieldNotification->customFieldWasUpdated($leadField, $userId);
}
/**
* @throws AbortColumnUpdateException
* @throws DriverException
* @throws LeadFieldWasNotFoundException
* @throws SchemaException
* @throws \Mautic\CoreBundle\Exception\SchemaException
*/
public function deleteColumn(int $leadFieldId, int $userId): void
{
$leadField = $this->fieldModel->getEntity($leadFieldId);
if (null === $leadField) {
throw new LeadFieldWasNotFoundException('LeadField entity was not found');
}
try {
$this->fieldColumnBackgroundJobDispatcher->dispatchPreDeleteColumnEvent($leadField);
} catch (NoListenerException) {
}
$this->customFieldColumn->processDeleteLeadColumn($leadField);
$this->leadFieldDeleter->deleteLeadFieldEntity($leadField, true);
$this->customFieldNotification->customFieldWasDeleted($leadField, $userId);
}
}

View File

@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Command;
use Mautic\LeadBundle\Field\SchemaDefinition;
use Mautic\LeadBundle\Model\FieldModel;
use Mautic\LeadBundle\Model\LeadModel;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsCommand(
name: 'mautic:fields:analyse',
description: 'Analyse actual usage of custom columns in leads table.'
)]
class AnalyseCustomFieldCommand extends Command
{
public function __construct(private FieldModel $fieldModel, private LeadModel $leadModel, private TranslatorInterface $translator)
{
parent::__construct();
}
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->addOption(
'display-table',
't',
InputOption::VALUE_NONE,
'Display results in table format'
);
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$displayAsTable = $input->getOption('display-table');
$fieldDetails = $this->getCustomFieldDetails();
if (empty($fieldDetails)) {
$output->writeln('No custom field(s) to analyse!!!');
return Command::SUCCESS;
}
$results = $this->leadModel->getCustomLeadFieldLength(array_keys($fieldDetails));
$fieldLengths = [];
foreach ($results as $key => $detail) {
$fieldLengths[$key] = ['max_length' => $detail];
}
$analysisDetails = array_merge_recursive($fieldDetails, $fieldLengths);
$headers = [
$this->translator->trans('mautic.lead.field.analyse.header.name'),
$this->translator->trans('mautic.lead.field.analyse.header.alias'),
$this->translator->trans('mautic.lead.field.analyse.header.length'),
$this->translator->trans('mautic.lead.field.analyse.header.max_length'),
$this->translator->trans('mautic.lead.field.analyse.header.suggested_length'),
$this->translator->trans('mautic.lead.field.analyse.header.indexed'),
];
$rows = [];
foreach ($analysisDetails as $analysisDetail) {
$maxLength = (int) $analysisDetail['max_length'] ?: 0;
$columnLength = (int) $analysisDetail['char_length_limit'] ?: 0;
$suggestedMaxSize = $this->getSuggestedMaxSize($columnLength, $maxLength);
$label = $analysisDetail['label'];
$rows[] = [
"\"$label\"",
$analysisDetail['alias'],
$columnLength,
$maxLength,
$suggestedMaxSize,
$analysisDetail['is_index'] ? $this->translator->trans('mautic.core.yes') : $this->translator->trans('mautic.core.no'),
];
}
if ($displayAsTable) {
$table = new Table($output);
$table
->setHeaders($headers)
->setRows($rows);
$table->render();
} else {
$output->writeln(implode(', ', $headers));
foreach ($rows as $row) {
$output->writeln(implode(', ', $row));
}
}
return Command::SUCCESS;
}
/**
* @return mixed[]
*/
private function getCustomFieldDetails(): array
{
$fields = $this->fieldModel->getLeadFieldCustomFields();
$fieldSchemas = $this->fieldModel->getLeadFieldCustomFieldSchemaDetails();
$fieldDetails = [];
foreach ($fields as $field) {
if (!isset($fieldSchemas[$field->getAlias()])) {
continue;
}
$schemaDef = SchemaDefinition::getSchemaDefinition($field->getAlias(), $field->getType(), $field->getIsUniqueIdentifier(), $field->getCharLengthLimit());
if ('string' !== $schemaDef['type']) {
continue;
}
$fieldDetails[$field->getAlias()] = [
'label' => $field->getLabel(),
'alias' => $field->getAlias(),
'type' => $schemaDef['type'],
'char_length_limit' => $fieldSchemas[$field->getAlias()]->getLength(),
'is_index' => $field->isIsIndex(),
];
}
return $fieldDetails;
}
private function getSuggestedMaxSize(int $columnLength, int $utilisedLength): int
{
if ($utilisedLength > 0) {
if (191 < $utilisedLength) {
return $columnLength;
}
return min($utilisedLength * 2, $columnLength, 191);
}
return min($columnLength, 191);
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Command;
use Doctrine\DBAL\Exception\DriverException;
use Doctrine\DBAL\Schema\SchemaException;
use Mautic\CoreBundle\Command\ModeratedCommand;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\PathsHelper;
use Mautic\LeadBundle\Entity\LeadFieldRepository;
use Mautic\LeadBundle\Field\BackgroundService;
use Mautic\LeadBundle\Field\Exception\AbortColumnCreateException;
use Mautic\LeadBundle\Field\Exception\ColumnAlreadyCreatedException;
use Mautic\LeadBundle\Field\Exception\CustomFieldLimitException;
use Mautic\LeadBundle\Field\Exception\LeadFieldWasNotFoundException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsCommand(
name: CreateCustomFieldCommand::COMMAND_NAME,
description: 'Create custom field column in the background',
)]
class CreateCustomFieldCommand extends ModeratedCommand
{
public const COMMAND_NAME = 'mautic:custom-field:create-column';
public function __construct(
private BackgroundService $backgroundService,
private TranslatorInterface $translator,
private LeadFieldRepository $leadFieldRepository,
PathsHelper $pathsHelper,
CoreParametersHelper $coreParametersHelper,
) {
parent::__construct($pathsHelper, $coreParametersHelper);
}
public function configure(): void
{
parent::configure();
$this
->addOption('--id', '-i', InputOption::VALUE_OPTIONAL, 'LeadField ID.')
->addOption('--user', '-u', InputOption::VALUE_OPTIONAL, 'User ID - User which receives a notification.')
->setHelp(
<<<'EOT'
The <info>%command.name%</info> command will create columns in a lead_fields table if the process should run in background.
<info>php %command.full_name%</info>
EOT
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$leadFieldId = (int) $input->getOption('id');
$userId = (int) $input->getOption('user');
if ($leadFieldId) {
return $this->addColumn($leadFieldId, $userId, $input, $output);
}
return $this->addAllMissingColumns($input, $output);
}
private function addAllMissingColumns(InputInterface $input, OutputInterface $output): int
{
$hasNoErrors = Command::SUCCESS;
while ($leadField = $this->leadFieldRepository->getFieldThatIsMissingColumn()) {
if (Command::FAILURE === $this->addColumn($leadField->getId(), $leadField->getCreatedBy(), $input, $output)) {
$hasNoErrors = Command::FAILURE;
}
}
return $hasNoErrors;
}
private function addColumn(int $leadFieldId, ?int $userId, InputInterface $input, OutputInterface $output): int
{
$moderationKey = sprintf('%s-%s-%s', self::COMMAND_NAME, $leadFieldId, $userId);
if (!$this->checkRunStatus($input, $output, $moderationKey)) {
return Command::SUCCESS;
}
try {
$this->backgroundService->addColumn($leadFieldId, $userId);
} catch (LeadFieldWasNotFoundException) {
$output->writeln('<error>'.$this->translator->trans('mautic.lead.field.notfound').'</error>');
return Command::FAILURE;
} catch (ColumnAlreadyCreatedException) {
$output->writeln('<error>'.$this->translator->trans('mautic.lead.field.column_already_created').'</error>');
return Command::SUCCESS;
} catch (AbortColumnCreateException) {
$output->writeln('<error>'.$this->translator->trans('mautic.lead.field.column_creation_aborted').'</error>');
return Command::SUCCESS;
} catch (CustomFieldLimitException|DriverException|SchemaException|\Doctrine\DBAL\Exception|\Mautic\CoreBundle\Exception\SchemaException $e) {
$output->writeln('<error>'.$this->translator->trans($e->getMessage()).'</error>');
return Command::FAILURE;
}
$output->writeln('<info>'.$this->translator->trans('mautic.lead.field.column_was_created', ['%id%' => $leadFieldId]).'</info>');
$this->completeRun();
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Command;
use Doctrine\DBAL\Exception\DriverException;
use Doctrine\DBAL\Schema\SchemaException;
use Mautic\LeadBundle\Entity\LeadField;
use Mautic\LeadBundle\Entity\LeadFieldRepository;
use Mautic\LeadBundle\Field\BackgroundService;
use Mautic\LeadBundle\Field\Exception\AbortColumnUpdateException;
use Mautic\LeadBundle\Field\Exception\LeadFieldWasNotFoundException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final class DeleteCustomFieldCommand extends Command
{
public function __construct(
private BackgroundService $backgroundService,
private TranslatorInterface $translator,
private LeadFieldRepository $leadFieldRepository,
) {
parent::__construct();
}
public function configure(): void
{
parent::configure();
$this->setName('mautic:custom-field:delete-column')
->setDescription('Delete custom field column in the background')
->addOption('--id', '-i', InputOption::VALUE_REQUIRED, 'LeadField ID.')
->addOption('--user', '-u', InputOption::VALUE_OPTIONAL, 'User ID - User which receives a notification.')
->setHelp(
<<<'EOT'
The <info>%command.name%</info> command will delete a column in a lead_fields table if the proces should run in background.
<info>php %command.full_name%</info>
EOT
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$leadFieldId = (int) $input->getOption('id');
$userId = (int) $input->getOption('user');
// Field ID wasn't provided. Try to find a field that is marked for deletion.
if (!$leadFieldId) {
/** @var ?LeadField $field */
$field = $this->leadFieldRepository->findOneBy(['columnIsNotRemoved' => true]);
if ($field) {
$output->writeln('<info>'.$this->translator->trans(
'mautic.lead.field.column_was_found_for_deletion',
['%fieldName%' => $field->getName(), '%fieldId%' => $field->getId()]
).'</info>');
$leadFieldId = $field->getId();
if (!$userId && $field->getModifiedBy()) {
$userId = $field->getModifiedBy();
}
}
}
try {
$this->backgroundService->deleteColumn($leadFieldId, $userId);
} catch (LeadFieldWasNotFoundException) {
$output->writeln('<error>'.$this->translator->trans('mautic.lead.field.notfound').'</error>');
return Command::FAILURE;
} catch (AbortColumnUpdateException) {
$output->writeln('<error>'.$this->translator->trans('mautic.lead.field.column_delete_aborted').'</error>');
return Command::SUCCESS;
} catch (DriverException|SchemaException|\Mautic\CoreBundle\Exception\SchemaException $e) {
$output->writeln('<error>'.$this->translator->trans($e->getMessage()).'</error>');
return Command::FAILURE;
}
$output->writeln('');
$output->writeln('<info>'.$this->translator->trans('mautic.lead.field.column_was_deleted').'</info>');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Command;
use Mautic\LeadBundle\Entity\LeadField;
use Mautic\LeadBundle\Model\FieldModel;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final class ModifyCustomFieldCommand extends Command
{
public function __construct(private FieldModel $fieldModel, private TranslatorInterface $translator)
{
parent::__construct();
}
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->setName('mautic:fields:modify')
->setDescription('Change the sizes of the fields')
->addArgument(
'csv-path',
InputArgument::REQUIRED,
'Path to a CSV file containing alteration details.'
);
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$csvPath = $input->getArgument('csv-path');
try {
$inputCsv = new \SplFileObject($csvPath);
} catch (\RuntimeException|\LogicException $e) {
$output->writeln(sprintf('<error>Could not open file "%s" because of error "%s".</error>', $csvPath, $e->getMessage()));
return Command::FAILURE;
}
$fieldData = $this->convertCsvToArray($inputCsv);
$fieldsNeedsToBeUpdated = [];
foreach ($fieldData as $field) {
if ($field['length'] === $field['suggested_length']) {
continue;
}
if ($field['suggested_length'] < 1 || $field['suggested_length'] > LeadField::MAX_VARCHAR_LENGTH) {
$output->writeln(sprintf('<comment>Skipping "%s", the suggested length must be between 1 and %s.</comment>', $field['name'], LeadField::MAX_VARCHAR_LENGTH));
continue;
}
$fieldsNeedsToBeUpdated[$field['alias']] = $field;
}
if (empty($fieldsNeedsToBeUpdated)) {
$output->writeln('<info>No custom field(s) to update!!!</info>');
return Command::SUCCESS;
}
$lists = $this->getCustomFieldsByAliases(array_keys($fieldsNeedsToBeUpdated));
foreach ($lists as $field) {
$field->setCharLengthLimit((int) $fieldsNeedsToBeUpdated[$field->getAlias()]['suggested_length']);
}
$this->fieldModel->saveEntities($lists);
$output->writeln(sprintf('<info>%s Field(s) updated successfully.</info>', count($fieldsNeedsToBeUpdated)));
return Command::SUCCESS;
}
/**
* @return mixed[]
*/
private function convertCsvToArray(\SplFileObject $inputCsv): array
{
$inputCsv->setFlags(\SplFileObject::READ_CSV | \SplFileObject::SKIP_EMPTY | \SplFileObject::DROP_NEW_LINE);
$headerSkipped = false;
$keys = [];
$data = [];
foreach ($inputCsv as $row) {
if (false === $row) {
// skip the last empty row
continue;
}
$row = array_map('trim', $row);
// skip the first(header) row
if (!$headerSkipped) {
$headerSkipped = true;
$keys = $this->getRowKeys($row);
continue;
}
$data[] = array_combine($keys, $row);
}
return $data;
}
/**
* @param string[] $aliases
*
* @return LeadField[]
*/
private function getCustomFieldsByAliases(array $aliases): array
{
$filters = [
[
'column' => 'f.object',
'expr' => 'like',
'value' => 'lead',
],
[
'column' => 'f.alias',
'expr' => 'in',
'value' => $aliases,
],
];
$args = [
'filter' => [
'force' => $filters,
],
'ignore_paginator' => true,
];
return $this->fieldModel->getEntities($args);
}
/**
* @param string[] $row
*
* @return string[]
*/
private function getRowKeys(array $row): array
{
$headers = [
'name' => $this->translator->trans('mautic.lead.field.analyse.header.name'),
'alias' => $this->translator->trans('mautic.lead.field.analyse.header.alias'),
'length' => $this->translator->trans('mautic.lead.field.analyse.header.length'),
'max_length' => $this->translator->trans('mautic.lead.field.analyse.header.max_length'),
'suggested_length' => $this->translator->trans('mautic.lead.field.analyse.header.suggested_length'),
'isIndexed' => $this->translator->trans('mautic.lead.field.analyse.header.indexed'),
];
return array_map(fn ($val) => array_search($val, $headers), $row);
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Command;
use Doctrine\DBAL\Exception as DBALException;
use Doctrine\DBAL\Exception\DriverException;
use Doctrine\DBAL\Schema\SchemaException;
use Mautic\LeadBundle\Field\BackgroundService;
use Mautic\LeadBundle\Field\Exception\AbortColumnUpdateException;
use Mautic\LeadBundle\Field\Exception\LeadFieldWasNotFoundException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsCommand(
name: 'mautic:custom-field:update-column',
description: 'Create custom field column in the background'
)]
class UpdateCustomFieldCommand extends Command
{
public function __construct(private BackgroundService $backgroundService, private TranslatorInterface $translator)
{
parent::__construct();
}
public function configure(): void
{
parent::configure();
$this
->addOption('--id', '-i', InputOption::VALUE_REQUIRED, 'LeadField ID.')
->addOption('--user', '-u', InputOption::VALUE_OPTIONAL, 'User ID - User which receives a notification.')
->setHelp(
<<<'EOT'
The <info>%command.name%</info> command will create a column in a lead_fields table if the proces should run in background.
<info>php %command.full_name%</info>
EOT
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$leadFieldId = (int) $input->getOption('id');
$userId = (int) $input->getOption('user');
try {
$this->backgroundService->updateColumn($leadFieldId, $userId);
} catch (LeadFieldWasNotFoundException) {
$output->writeln('<error>'.$this->translator->trans('mautic.lead.field.notfound').'</error>');
return Command::FAILURE;
} catch (AbortColumnUpdateException) {
$output->writeln('<error>'.$this->translator->trans('mautic.lead.field.column_update_aborted').'</error>');
return Command::SUCCESS;
} catch (DriverException|SchemaException|DBALException|\Mautic\CoreBundle\Exception\SchemaException $e) {
$output->writeln('<error>'.$this->translator->trans($e->getMessage()).'</error>');
return Command::FAILURE;
}
$output->writeln('');
$output->writeln('<info>'.$this->translator->trans('mautic.lead.field.column_was_updated').'</info>');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field;
use Doctrine\DBAL\Exception\DriverException;
use Mautic\CoreBundle\Doctrine\Helper\ColumnSchemaHelper;
use Mautic\CoreBundle\Exception\SchemaException;
use Mautic\LeadBundle\Entity\LeadField;
use Mautic\LeadBundle\Exception\NoListenerException;
use Mautic\LeadBundle\Field\Dispatcher\FieldColumnDispatcher;
use Mautic\LeadBundle\Field\Exception\AbortColumnCreateException;
use Mautic\LeadBundle\Field\Exception\AbortColumnUpdateException;
use Mautic\LeadBundle\Field\Exception\CustomFieldLimitException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class CustomFieldColumn
{
public function __construct(
private ColumnSchemaHelper $columnSchemaHelper,
private SchemaDefinition $schemaDefinition,
private LoggerInterface $logger,
private LeadFieldSaver $leadFieldSaver,
private CustomFieldIndex $customFieldIndex,
private FieldColumnDispatcher $fieldColumnDispatcher,
private TranslatorInterface $translator,
) {
}
/**
* @throws AbortColumnCreateException
* @throws AbortColumnUpdateException
* @throws CustomFieldLimitException
* @throws \Doctrine\DBAL\Exception
* @throws DriverException
* @throws \Doctrine\DBAL\Schema\SchemaException
* @throws SchemaException
*/
public function createLeadColumn(LeadField $leadField): void
{
$leadsSchema = $this->columnSchemaHelper->setName($leadField->getCustomFieldObject());
// We have to check if the LeadField entity is new and the column already exists .
// In such case we must throw an exception to warn users that the column already exists.
try {
$columnExists = $leadsSchema->checkColumnExists($leadField->getAlias(), $leadField->isNew());
if ($columnExists && $this->customFieldIndex->isUpdatePending($leadField)) {
try {
$this->fieldColumnDispatcher->dispatchPreUpdateColumnEvent($leadField);
} catch (NoListenerException) {
}
$this->processUpdateLeadColumn($leadField);
}
if ($columnExists) {
return;
}
} catch (SchemaException) {
// We use slightly different error message if the column already exists in this case.
throw new SchemaException($this->translator->trans('mautic.lead.field.column.already.exists', ['%field%' => $leadField->getName()], 'validators'));
}
try {
$this->fieldColumnDispatcher->dispatchPreAddColumnEvent($leadField);
} catch (AbortColumnCreateException $e) {
// Save the field metadata and throw the exception again to stop column creation.
// As the column should be created by a background job.
$this->leadFieldSaver->saveLeadFieldEntityWithoutColumnCreated($leadField);
throw $e;
}
$this->processCreateLeadColumn($leadField);
}
/**
* Create the field as its own column in the leads table.
*
* @throws CustomFieldLimitException
* @throws DriverException
* @throws \Doctrine\DBAL\Schema\SchemaException
* @throws SchemaException
*/
public function processCreateLeadColumn(LeadField $leadField, bool $saveLeadField = true): void
{
$leadsSchema = $this->columnSchemaHelper->setName($leadField->getCustomFieldObject());
// Check if column do not exist. This method could be called from plugins too.
if ($leadsSchema->checkColumnExists($leadField->getAlias())) {
return;
}
$schemaDefinition = $this->schemaDefinition->getSchemaDefinitionNonStatic(
$leadField->getAlias(),
$leadField->getType(),
(bool) $leadField->getIsUniqueIdentifier(),
(int) $leadField->getCharLengthLimit()
);
$leadsSchema->addColumn($schemaDefinition);
try {
$leadsSchema->executeChanges();
} catch (DriverException $e) {
$this->logger->warning($e->getMessage());
if (1118 === $e->getCode() /* ER_TOO_BIG_ROWSIZE */) {
throw new CustomFieldLimitException('mautic.lead.field.max_column_error');
}
throw $e;
}
if ($saveLeadField) {
// $leadField is a new entity (this is not executed for update), it was successfully added to the lead table > save it
$this->leadFieldSaver->saveLeadFieldEntity($leadField, true);
}
if ($leadField->isIsIndex() || $leadField->getIsUniqueIdentifier()) {
$this->customFieldIndex->addIndexOnColumn($leadField);
}
}
/**
* Updates the field column in the leads table.
*
* @throws DriverException
* @throws \Doctrine\DBAL\Schema\SchemaException
* @throws SchemaException
*/
public function processUpdateLeadColumn(LeadField $leadField): void
{
$hasIndex = $this->customFieldIndex->hasIndex($leadField);
if ($leadField->isIsIndex() && !$hasIndex) {
$this->customFieldIndex->addIndexOnColumn($leadField);
} elseif (!$leadField->isIsIndex() && $hasIndex) {
$this->customFieldIndex->dropIndexOnColumn($leadField);
}
$this->customFieldIndex->updateUniqueIdentifierIndex($leadField);
}
/**
* @throws SchemaException
* @throws \OutOfRangeException
*/
public function updateLeadColumn(LeadField $leadField): void
{
try {
$this->fieldColumnDispatcher->dispatchPreUpdateColumnEvent($leadField);
} catch (NoListenerException) {
} catch (AbortColumnUpdateException) { // if processing in background
return;
}
$this->processUpdateLeadColumn($leadField);
$this->processUpdateLeadColumnLength($leadField);
}
/**
* @throws SchemaException
* @throws \OutOfRangeException
*/
public function processUpdateLeadColumnLength(LeadField $leadField): void
{
$leadsSchema = $this->columnSchemaHelper->setName($leadField->getCustomFieldObject());
$leadsSchema->updateColumnLength($leadField->getAlias(), $leadField->getCharLengthLimit());
$leadsSchema->executeChanges();
}
/**
* Register a lead field to be deleted.
*
* @throws \Doctrine\DBAL\Exception
* @throws DriverException
* @throws \Doctrine\DBAL\Schema\SchemaException
*/
public function deleteLeadColumn(LeadField $leadField): void
{
try {
$this->fieldColumnDispatcher->dispatchPreDeleteColumnEvent($leadField);
} catch (NoListenerException) {
} catch (AbortColumnUpdateException) { // if processing in background
return;
}
$this->processDeleteLeadColumn($leadField);
}
/**
* Deletes the field column in the leads table.
*
* @throws DriverException
* @throws \Doctrine\DBAL\Schema\SchemaException
* @throws SchemaException
*/
public function processDeleteLeadColumn(LeadField $leadField): void
{
$leadField->deletedId = $leadField->getId();
switch ($leadField->getObject()) {
case 'lead':
$this->columnSchemaHelper->setName('leads')->dropColumn($leadField->getAlias())->executeChanges();
break;
case 'company':
$this->columnSchemaHelper->setName('companies')->dropColumn($leadField->getAlias())->executeChanges();
break;
}
$this->columnSchemaHelper->dropColumn($leadField->getCustomFieldObject());
}
}

View File

@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field;
use Doctrine\DBAL\Exception\DriverException;
use Doctrine\DBAL\Schema\SchemaException as DoctrineSchemaException;
use Mautic\CoreBundle\Doctrine\Helper\IndexSchemaHelper;
use Mautic\CoreBundle\Exception\SchemaException;
use Mautic\LeadBundle\Entity\LeadField;
use Psr\Log\LoggerInterface;
class CustomFieldIndex
{
public function __construct(
private IndexSchemaHelper $indexSchemaHelper,
private LoggerInterface $logger,
private FieldsWithUniqueIdentifier $fieldsWithUniqueIdentifier,
) {
}
/**
* Update the unique_identifier_search index and add an index for this field.
*
* @throws DriverException
* @throws DoctrineSchemaException
* @throws SchemaException
*/
public function addIndexOnColumn(LeadField $leadField): void
{
try {
/** @var IndexSchemaHelper $modifySchema */
$modifySchema = $this->indexSchemaHelper->setName($leadField->getCustomFieldObject());
$alias = $leadField->getAlias();
$modifySchema->addIndex([$alias], $alias.'_search');
$modifySchema->allowColumn($alias);
$this->updateUniqueIdentifierIndex($leadField);
$modifySchema->executeChanges();
} catch (DriverException $e) {
if (1069 === $e->getCode() /* ER_TOO_MANY_KEYS */) {
$this->logger->warning($e->getMessage());
} else {
throw $e;
}
}
}
/**
* Updates the index for this field.
*
* @throws DriverException
* @throws DoctrineSchemaException
* @throws SchemaException
*/
public function dropIndexOnColumn(LeadField $leadField): void
{
try {
/** @var IndexSchemaHelper $modifySchema */
$modifySchema = $this->indexSchemaHelper->setName($leadField->getCustomFieldObject());
$alias = $leadField->getAlias();
$modifySchema->dropIndex([$alias], $alias.'_search');
$modifySchema->allowColumn($alias);
$modifySchema->executeChanges();
} catch (DriverException $e) {
if (1069 === $e->getCode() /* ER_TOO_MANY_KEYS */) {
$this->logger->warning($e->getMessage());
} else {
throw $e;
}
}
}
/**
* @throws SchemaException
*/
public function isUpdatePending(LeadField $leadField): bool
{
$hasIndex = $this->hasIndex($leadField);
if ($leadField->isIsIndex() !== $hasIndex) {
return true;
}
if (!$this->hasMatchingUniqueIdentifierIndex($leadField)) {
return true;
}
return false;
}
/**
* @throws SchemaException
*/
public function hasIndex(LeadField $leadField): bool
{
return $this->indexSchemaHelper->hasIndex($leadField);
}
public function hasMatchingUniqueIdentifierIndex(LeadField $leadField): bool
{
$uniqueIdentifierColumns = $this->getUniqueIdentifierIndexColumns($leadField->getObject());
try {
return $this->indexSchemaHelper->hasMatchingUniqueIdentifierIndex($leadField, $uniqueIdentifierColumns);
} catch (DoctrineSchemaException) {
// Return true only if there are no unique identifier fields but otherwise assume the index is missing
return 0 === count($uniqueIdentifierColumns);
}
}
/**
* @throws DoctrineSchemaException
* @throws SchemaException
*/
public function updateUniqueIdentifierIndex(LeadField $leadField): void
{
if ($this->hasMatchingUniqueIdentifierIndex($leadField)) {
return;
}
/** @var IndexSchemaHelper $modifySchema */
$modifySchema = $this->indexSchemaHelper->setName($leadField->getCustomFieldObject());
$indexColumns = $this->getUniqueIdentifierIndexColumns($leadField->getObject());
if (!$indexColumns) {
$this->dropIndexForUniqueIdentifiers($leadField);
return;
}
$modifySchema->addIndex($indexColumns, 'unique_identifier_search');
$modifySchema->executeChanges();
}
/**
* @throws DoctrineSchemaException
* @throws SchemaException
*/
private function dropIndexForUniqueIdentifiers(LeadField $leadField): void
{
if (!$this->indexSchemaHelper->hasUniqueIdentifierIndex($leadField)) {
return;
}
/** @var IndexSchemaHelper $modifySchema */
$modifySchema = $this->indexSchemaHelper->setName($leadField->getCustomFieldObject());
$indexColumns = $this->getUniqueIdentifierIndexColumns($leadField->getObject());
$modifySchema->dropIndex($indexColumns, 'unique_identifier_search');
$modifySchema->executeChanges();
}
/**
* @return array<mixed>
*/
private function getUniqueIdentifierIndexColumns(string $object = 'lead'): array
{
// Filters
$filters = ['object' => $object];
// Get list of current uniques
$uniqueIdentifierFields = $this->fieldsWithUniqueIdentifier->getLiveFields($filters);
// Always use email
$indexColumns = array_keys($uniqueIdentifierFields);
// Only use three to prevent max key length errors
return array_slice($indexColumns, 0, 3);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\DTO;
use Mautic\LeadBundle\Entity\LeadField;
use Mautic\LeadBundle\Exception\InvalidObjectTypeException;
class CustomFieldObject
{
private array $objects = [
'lead' => 'leads',
'company' => 'companies',
];
private LeadField $leadField;
/**
* @throws InvalidObjectTypeException
*/
public function __construct(LeadField $leadField)
{
$leadFieldObject = $leadField->getObject();
if (!isset($this->objects[$leadFieldObject])) {
throw new InvalidObjectTypeException($leadFieldObject.' has no associated object.');
}
$this->leadField = $leadField;
}
public function getObject(): string
{
return $this->objects[$this->leadField->getObject()];
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Dispatcher;
use Mautic\LeadBundle\Entity\LeadField;
use Mautic\LeadBundle\Exception\NoListenerException;
use Mautic\LeadBundle\Field\Event\AddColumnBackgroundEvent;
use Mautic\LeadBundle\Field\Event\DeleteColumnBackgroundEvent;
use Mautic\LeadBundle\Field\Event\UpdateColumnBackgroundEvent;
use Mautic\LeadBundle\Field\Exception\AbortColumnCreateException;
use Mautic\LeadBundle\Field\Exception\AbortColumnUpdateException;
use Mautic\LeadBundle\LeadEvents;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class FieldColumnBackgroundJobDispatcher
{
public function __construct(
private EventDispatcherInterface $dispatcher,
) {
}
/**
* @throws AbortColumnCreateException
* @throws NoListenerException
*/
public function dispatchPreAddColumnEvent(LeadField $leadField): void
{
$action = LeadEvents::LEAD_FIELD_PRE_ADD_COLUMN_BACKGROUND_JOB;
if (!$this->dispatcher->hasListeners($action)) {
throw new NoListenerException('There is no Listener for this event');
}
$event = new AddColumnBackgroundEvent($leadField);
$this->dispatcher->dispatch($event, $action);
if ($event->isPropagationStopped()) {
throw new AbortColumnCreateException('Column cannot be created now');
}
}
/**
* @throws AbortColumnUpdateException
* @throws NoListenerException
*/
public function dispatchPreUpdateColumnEvent(LeadField $leadField): void
{
$action = LeadEvents::LEAD_FIELD_PRE_UPDATE_COLUMN_BACKGROUND_JOB;
if (!$this->dispatcher->hasListeners($action)) {
throw new NoListenerException('There is no Listener for this event');
}
$event = new UpdateColumnBackgroundEvent($leadField);
$this->dispatcher->dispatch($event, $action);
if ($event->isPropagationStopped()) {
throw new AbortColumnUpdateException('Column cannot be updated now');
}
}
/**
* @throws AbortColumnUpdateException
* @throws NoListenerException
*/
public function dispatchPreDeleteColumnEvent(LeadField $leadField): void
{
$action = LeadEvents::LEAD_FIELD_PRE_DELETE_COLUMN_BACKGROUND_JOB;
if (!$this->dispatcher->hasListeners($action)) {
throw new NoListenerException('There is no Listener for this event');
}
$event = new DeleteColumnBackgroundEvent($leadField);
$this->dispatcher->dispatch($event, $action);
if ($event->isPropagationStopped()) {
throw new AbortColumnUpdateException('Column cannot be deleted now');
}
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Dispatcher;
use Mautic\LeadBundle\Entity\LeadField;
use Mautic\LeadBundle\Exception\NoListenerException;
use Mautic\LeadBundle\Field\Event\AddColumnEvent;
use Mautic\LeadBundle\Field\Event\DeleteColumnEvent;
use Mautic\LeadBundle\Field\Event\UpdateColumnEvent;
use Mautic\LeadBundle\Field\Exception\AbortColumnCreateException;
use Mautic\LeadBundle\Field\Exception\AbortColumnUpdateException;
use Mautic\LeadBundle\Field\Settings\BackgroundSettings;
use Mautic\LeadBundle\LeadEvents;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class FieldColumnDispatcher
{
public function __construct(
private EventDispatcherInterface $dispatcher,
private BackgroundSettings $backgroundSettings,
) {
}
/**
* @throws AbortColumnCreateException
*/
public function dispatchPreAddColumnEvent(LeadField $leadField): void
{
$shouldProcessInBackground = $this->backgroundSettings->shouldProcessColumnChangeInBackground();
$event = new AddColumnEvent($leadField, $shouldProcessInBackground);
$this->dispatcher->dispatch($event, LeadEvents::LEAD_FIELD_PRE_ADD_COLUMN);
if ($shouldProcessInBackground) {
throw new AbortColumnCreateException('Column change will be processed in background job');
}
}
/**
* @throws AbortColumnUpdateException
* @throws NoListenerException
*/
public function dispatchPreUpdateColumnEvent(LeadField $leadField): void
{
$action = LeadEvents::LEAD_FIELD_PRE_UPDATE_COLUMN;
if (!$this->dispatcher->hasListeners($action)) {
throw new NoListenerException('There is no Listener for this event');
}
$shouldProcessInBackground = $this->backgroundSettings->shouldProcessColumnChangeInBackground();
$event = new UpdateColumnEvent($leadField, $shouldProcessInBackground);
$this->dispatcher->dispatch($event, $action);
if ($event->shouldProcessInBackground()) {
throw new AbortColumnUpdateException('Column change will be processed in background job');
}
}
/**
* @throws AbortColumnUpdateException
* @throws NoListenerException
*/
public function dispatchPreDeleteColumnEvent(LeadField $leadField): void
{
$action = LeadEvents::LEAD_FIELD_PRE_DELETE_COLUMN;
if (!$this->dispatcher->hasListeners($action)) {
throw new NoListenerException('There is no Listener for this event');
}
$shouldProcessInBackground = $this->backgroundSettings->shouldProcessColumnChangeInBackground();
$event = new DeleteColumnEvent($leadField, $shouldProcessInBackground);
$this->dispatcher->dispatch($event, $action);
if ($event->shouldProcessInBackground()) {
throw new AbortColumnUpdateException('Column delete will be processed in background job');
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Dispatcher;
use Doctrine\ORM\EntityManager;
use Mautic\LeadBundle\Entity\LeadField;
use Mautic\LeadBundle\Event\LeadFieldEvent;
use Mautic\LeadBundle\Exception\NoListenerException;
use Mautic\LeadBundle\Field\Exception\AbortColumnUpdateException;
use Mautic\LeadBundle\Field\Settings\BackgroundSettings;
use Mautic\LeadBundle\LeadEvents;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class FieldDeleteDispatcher
{
public function __construct(
private EventDispatcherInterface $dispatcher,
private EntityManager $entityManager,
private BackgroundSettings $backgroundSettings,
) {
}
/**
* @throws NoListenerException
* @throws AbortColumnUpdateException
*/
public function dispatchPreDeleteEvent(LeadField $entity): LeadFieldEvent
{
if ($this->backgroundSettings->shouldProcessColumnChangeInBackground()) {
throw new AbortColumnUpdateException('Column change will be processed in background job');
}
return $this->dispatchEvent(LeadEvents::FIELD_PRE_DELETE, $entity);
}
/**
* @throws NoListenerException
*/
public function dispatchPostDeleteEvent(LeadField $entity): LeadFieldEvent
{
return $this->dispatchEvent(LeadEvents::FIELD_POST_DELETE, $entity);
}
/**
* @param string $action - Use constant from LeadEvents class (e.g. LeadEvents::FIELD_PRE_SAVE)
*
* @throws NoListenerException
*/
private function dispatchEvent($action, LeadField $entity, ?LeadFieldEvent $event = null): LeadFieldEvent
{
if (!$this->dispatcher->hasListeners($action)) {
throw new NoListenerException('There is no Listener for this event');
}
if (null === $event) {
$event = new LeadFieldEvent($entity);
$event->setEntityManager($this->entityManager);
}
$this->dispatcher->dispatch($event, $action);
return $event;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Dispatcher;
use Doctrine\ORM\EntityManager;
use Mautic\LeadBundle\Entity\LeadField;
use Mautic\LeadBundle\Event\LeadFieldEvent;
use Mautic\LeadBundle\Exception\NoListenerException;
use Mautic\LeadBundle\LeadEvents;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class FieldSaveDispatcher
{
public function __construct(
private EventDispatcherInterface $dispatcher,
private EntityManager $entityManager,
) {
}
/**
* @throws NoListenerException
*/
public function dispatchPreSaveEvent(LeadField $entity, bool $isNew): LeadFieldEvent
{
return $this->dispatchEvent(LeadEvents::FIELD_PRE_SAVE, $entity, $isNew);
}
/**
* @throws NoListenerException
*/
public function dispatchPostSaveEvent(LeadField $entity, bool $isNew): LeadFieldEvent
{
return $this->dispatchEvent(LeadEvents::FIELD_POST_SAVE, $entity, $isNew);
}
/**
* @throws NoListenerException
*/
public function dispatchEvent(string $action, LeadField $entity, bool $isNew, ?LeadFieldEvent $event = null): LeadFieldEvent
{
if (!$this->dispatcher->hasListeners($action)) {
throw new NoListenerException('There is no Listener for '.$action.' event');
}
if (null === $event) {
$event = new LeadFieldEvent($entity, $isNew);
$event->setEntityManager($this->entityManager);
}
$this->dispatcher->dispatch($event, $action);
return $event;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Event;
use Mautic\LeadBundle\Entity\LeadField;
use Symfony\Contracts\EventDispatcher\Event;
final class AddColumnBackgroundEvent extends Event
{
public function __construct(
private LeadField $leadField,
) {
}
public function getLeadField(): LeadField
{
return $this->leadField;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Event;
use Mautic\LeadBundle\Entity\LeadField;
use Symfony\Contracts\EventDispatcher\Event;
final class AddColumnEvent extends Event
{
public function __construct(
private LeadField $leadField,
private bool $shouldProcessInBackground,
) {
}
public function getLeadField(): LeadField
{
return $this->leadField;
}
public function shouldProcessInBackground(): bool
{
return $this->shouldProcessInBackground;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Event;
use Mautic\LeadBundle\Entity\LeadField;
use Symfony\Contracts\EventDispatcher\Event;
final class DeleteColumnBackgroundEvent extends Event
{
public function __construct(private LeadField $leadField)
{
}
public function getLeadField(): LeadField
{
return $this->leadField;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Event;
use Mautic\LeadBundle\Entity\LeadField;
use Symfony\Contracts\EventDispatcher\Event;
final class DeleteColumnEvent extends Event
{
public function __construct(private LeadField $leadField, private bool $shouldProcessInBackground)
{
}
public function getLeadField(): LeadField
{
return $this->leadField;
}
public function shouldProcessInBackground(): bool
{
return $this->shouldProcessInBackground;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Event;
use Mautic\LeadBundle\Entity\LeadField;
use Symfony\Contracts\EventDispatcher\Event;
final class UpdateColumnBackgroundEvent extends Event
{
public function __construct(private LeadField $leadField)
{
}
public function getLeadField(): LeadField
{
return $this->leadField;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Event;
use Mautic\LeadBundle\Entity\LeadField;
use Symfony\Contracts\EventDispatcher\Event;
final class UpdateColumnEvent extends Event
{
public function __construct(
private LeadField $leadField,
private bool $shouldProcessInBackground,
) {
}
public function getLeadField(): LeadField
{
return $this->leadField;
}
public function shouldProcessInBackground(): bool
{
return $this->shouldProcessInBackground;
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Exception;
class AbortColumnCreateException extends \Exception
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Exception;
class AbortColumnUpdateException extends \Exception
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Exception;
class ColumnAlreadyCreatedException extends \Exception
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Exception;
class CustomFieldLimitException extends \Exception
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Exception;
class LeadFieldWasNotFoundException extends \Exception
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Exception;
class NoUserException extends \Exception
{
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field;
use Mautic\CoreBundle\Cache\ResultCacheOptions;
use Mautic\LeadBundle\Entity\LeadField;
use Mautic\LeadBundle\Entity\LeadFieldRepository;
use Symfony\Contracts\Translation\TranslatorInterface;
class FieldList
{
public function __construct(
private LeadFieldRepository $leadFieldRepository,
private TranslatorInterface $translator,
) {
}
/**
* @param mixed[] $filters
*
* @return mixed[]
*/
public function getFieldList(bool $byGroup = true, bool $alphabetical = true, array $filters = ['isPublished' => true, 'object' => 'lead']): array
{
$forceFilters = [];
foreach ($filters as $col => $val) {
$forceFilters[] = [
'column' => "f.{$col}",
'expr' => 'eq',
'value' => $val,
];
}
// Get a list of custom form fields
$fields = $this->leadFieldRepository->getEntities([
'filter' => [
'force' => $forceFilters,
],
'orderBy' => 'f.order',
'orderByDir' => 'asc',
'result_cache' => new ResultCacheOptions(LeadField::CACHE_NAMESPACE),
]);
$leadFields = [];
foreach ($fields as $f) {
if ($byGroup) {
$fieldName = $this->translator->trans('mautic.lead.field.group.'.$f->getGroup());
$leadFields[$fieldName][$f->getAlias()] = $f->getLabel();
} else {
$leadFields[$f->getAlias()] = $f->getLabel();
}
}
if ($alphabetical) {
// Sort the groups
uksort($leadFields, 'strnatcmp');
if ($byGroup) {
// Sort each group by translation
foreach ($leadFields as &$fieldGroup) {
uasort($fieldGroup, 'strnatcmp');
}
}
}
return $leadFields;
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field;
class FieldsWithUniqueIdentifier
{
/**
* @var array<mixed>
*/
private array $uniqueIdentifierFields = [];
public function __construct(
private FieldList $fieldList,
) {
}
/**
* Retrieves a list of cached published fields that are unique identifiers.
*
* @param array<mixed> $filters
*
* @return array<mixed>
*/
public function getFieldsWithUniqueIdentifier(array $filters = []): array
{
$filters = $this->prepareFilters($filters);
$key = base64_encode(json_encode($filters));
if (!isset($this->uniqueIdentifierFields[$key])) {
$this->uniqueIdentifierFields[$key] = $this->fieldList->getFieldList(false, true, $filters);
}
return $this->uniqueIdentifierFields[$key];
}
/**
* Retrieves a list of published fields that are unique identifiers fresh from the DB each time.
*
* @param array<mixed> $filters
*
* @return array<mixed>
*/
public function getLiveFields(array $filters = []): array
{
$filters = $this->prepareFilters($filters);
return $this->fieldList->getFieldList(false, true, $filters);
}
/**
* @param array<mixed> $filters
*
* @return array<mixed>
*/
private function prepareFilters(array $filters): array
{
$filters['isPublished'] ??= true;
$filters['isUniqueIdentifer'] ??= true;
$filters['object'] ??= 'lead';
return $filters;
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Helper;
use Doctrine\DBAL\Exception as DBALException;
use Doctrine\ORM\EntityManager;
use Mautic\LeadBundle\Entity\Lead;
/**
* Helper for getting and counting indexes on lead table.
*
* @see Lead
*/
class IndexHelper
{
public const MAX_COUNT_ALLOWED = 64;
/**
* @var bool|array<string>
*/
private $indexedColumns = false;
/**
* Can be different from indexed column count when using multiple indexes on same table.
*/
private int $indexCount = 0;
public function __construct(private EntityManager $entityManager)
{
}
/**
* @return array<string>|bool
*/
public function getIndexedColumnNames()
{
$this->getIndexes();
return $this->indexedColumns;
}
public function getIndexCount(): int
{
$this->getIndexes();
return $this->indexCount;
}
public function getMaxCount(): int
{
return self::MAX_COUNT_ALLOWED;
}
public function isNewIndexAllowed(): bool
{
return $this->getIndexCount() < $this->getMaxCount();
}
/**
* Get indexes created on `leads` table.
*
* @see Lead
*
* @throws DBALException
*/
private function getIndexes(): void
{
if (false !== $this->indexedColumns) {
// Query below performed
return;
}
$tableName = $this->entityManager->getClassMetadata(Lead::class)->getTableName();
$sql = "SHOW INDEXES FROM `$tableName`";
$stmt = $this->entityManager->getConnection()->prepare($sql);
$indexes = $stmt->executeQuery()->fetchAllAssociative();
$this->indexedColumns = array_map(
fn ($index) => $index['Column_name'],
$indexes
);
$this->indexCount = count($indexes);
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field;
use Mautic\LeadBundle\Entity\Company;
use Mautic\LeadBundle\Entity\IdentifierFieldEntityInterface;
use Mautic\LeadBundle\Entity\Lead;
class IdentifierFields
{
public function __construct(
private FieldsWithUniqueIdentifier $fieldsWithUniqueIdentifier,
private FieldList $fieldList,
) {
}
/**
* @return string[]
*/
public function getFieldList(string $object, ?object $entityClass = null): array
{
return array_merge(
$this->getDefaultFields($object, $entityClass),
$this->getUniqueIdentifierFields($object),
$this->getSocialFields($object)
);
}
/**
* @return string[]
*/
private function getDefaultFields(string $object, ?object $entityClass): array
{
if (null === $entityClass) {
switch ($object) {
case 'lead':
$entityClass = Lead::class;
break;
case 'company':
$entityClass = Company::class;
break;
default:
return [];
}
}
if (is_subclass_of($entityClass, IdentifierFieldEntityInterface::class)) {
return $entityClass::getDefaultIdentifierFields();
}
// The class wasn't recognized or doesn't implement the interface
return [];
}
/**
* @return string[]
*/
private function getUniqueIdentifierFields(string $object): array
{
$fields = $this->fieldsWithUniqueIdentifier->getFieldsWithUniqueIdentifier(
[
'object' => $object,
]
);
return array_keys($fields);
}
/**
* @return string[]
*/
private function getSocialFields(string $object): array
{
$fields = $this->fieldList->getFieldList(
true,
false,
[
'isPublished' => true,
'object' => $object,
]
);
if (!isset($fields['Social'])) {
return [];
}
return array_keys($fields['Social']);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\LeadBundle\Entity\LeadField;
use Mautic\LeadBundle\Entity\LeadFieldRepository;
use Mautic\LeadBundle\Exception\NoListenerException;
use Mautic\LeadBundle\Field\Dispatcher\FieldDeleteDispatcher;
use Mautic\LeadBundle\Field\Exception\AbortColumnUpdateException;
class LeadFieldDeleter
{
public function __construct(
private LeadFieldRepository $leadFieldRepository,
private FieldDeleteDispatcher $fieldDeleteDispatcher,
private UserHelper $userHelper,
) {
}
/**
* @param bool $isBackground - if processing in background
*/
public function deleteLeadFieldEntity(LeadField $leadField, bool $isBackground = false): void
{
try {
$this->fieldDeleteDispatcher->dispatchPreDeleteEvent($leadField);
} catch (NoListenerException) {
} catch (AbortColumnUpdateException) { // if processing in background is ON
if (!$isBackground) {
$this->deleteLeadFieldEntityWithoutColumnRemoved($leadField);
return;
}
}
$leadField->deletedId = $leadField->getId();
$this->leadFieldRepository->deleteEntity($leadField);
try {
$this->fieldDeleteDispatcher->dispatchPostDeleteEvent($leadField);
} catch (NoListenerException) {
}
}
/**
* Marks the field for delation in the background and sets the modified by user who
* will be used as the user who will actually delete the field in the background.
* Such soft-deleted field will disappear from the UI.
*
* Note: The LeadModel would set most of this for us, but cannot be used due to circular dependency.
*/
private function deleteLeadFieldEntityWithoutColumnRemoved(LeadField $leadField): void
{
$currentUser = $this->userHelper->getUser();
$leadField->setColumnIsNotRemoved();
$leadField->setModifiedBy($currentUser);
$leadField->setModifiedByUser($currentUser?->getName());
$leadField->setDateModified((new DateTimeHelper())->getDateTime());
$leadField->setIsPublished(false);
$this->leadFieldRepository->saveEntity($leadField);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field;
use Mautic\LeadBundle\Entity\LeadField;
use Mautic\LeadBundle\Entity\LeadFieldRepository;
use Mautic\LeadBundle\Exception\NoListenerException;
use Mautic\LeadBundle\Field\Dispatcher\FieldSaveDispatcher;
class LeadFieldSaver
{
public function __construct(
private LeadFieldRepository $leadFieldRepository,
private FieldSaveDispatcher $fieldSaveDispatcher,
) {
}
public function saveLeadFieldEntity(LeadField $leadField, bool $isNew): void
{
try {
$this->fieldSaveDispatcher->dispatchPreSaveEvent($leadField, $isNew);
} catch (NoListenerException) {
}
$this->leadFieldRepository->saveEntity($leadField);
try {
$this->fieldSaveDispatcher->dispatchPostSaveEvent($leadField, $isNew);
} catch (NoListenerException) {
}
}
public function saveLeadFieldEntityWithoutColumnCreated(LeadField $leadField): void
{
$leadField->setColumnIsNotCreated();
$this->saveLeadFieldEntity($leadField, true);
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Notification;
use Mautic\CoreBundle\Model\NotificationModel;
use Mautic\LeadBundle\Entity\LeadField;
use Mautic\LeadBundle\Field\Exception\NoUserException;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Model\UserModel;
use Symfony\Contracts\Translation\TranslatorInterface;
class CustomFieldNotification
{
public function __construct(
private NotificationModel $notificationModel,
private UserModel $userModel,
private TranslatorInterface $translator,
) {
}
public function customFieldWasCreated(LeadField $leadField, ?int $userId): void
{
try {
$user = $this->getUser($userId);
} catch (NoUserException) {
return;
}
$message = $this->translator->trans(
'mautic.lead.field.notification.created_message',
['%label%' => $leadField->getLabel()]
);
$header = $this->translator->trans('mautic.lead.field.notification.created_header');
$this->addToNotificationCenter($user, $message, $header);
}
public function customFieldWasUpdated(LeadField $leadField, ?int $userId): void
{
try {
$user = $this->getUser($userId);
} catch (NoUserException) {
return;
}
$message = $this->translator->trans(
'mautic.lead.field.notification.updated_message',
['%label%' => $leadField->getLabel()]
);
$header = $this->translator->trans('mautic.lead.field.notification.updated_header');
$this->addToNotificationCenter($user, $message, $header);
}
public function customFieldWasDeleted(LeadField $leadField, int $userId): void
{
try {
$user = $this->getUser($userId);
} catch (NoUserException) {
return;
}
$message = $this->translator->trans(
'mautic.lead.field.notification.deleted_message',
['%label%' => $leadField->getLabel()]
);
$header = $this->translator->trans('mautic.lead.field.notification.deleted_header');
$this->addToNotificationCenter($user, $message, $header);
}
public function customFieldLimitWasHit(LeadField $leadField, ?int $userId): void
{
try {
$user = $this->getUser($userId);
} catch (NoUserException) {
return;
}
$message = $this->translator->trans(
'mautic.lead.field.notification.custom_field_limit_hit_message',
['%label%' => $leadField->getLabel()]
);
$header = $this->translator->trans('mautic.lead.field.notification.custom_field_limit_hit_header');
$this->addToNotificationCenter($user, $message, $header);
}
public function customFieldCannotBeCreated(LeadField $leadField, ?int $userId): void
{
try {
$user = $this->getUser($userId);
} catch (NoUserException) {
return;
}
$message = $this->translator->trans(
'mautic.lead.field.notification.cannot_be_created_message',
['%label%' => $leadField->getLabel()]
);
$header = $this->translator->trans('mautic.lead.field.notification.cannot_be_created_header');
$this->addToNotificationCenter($user, $message, $header);
}
public function customFieldCannotBeUpdated(LeadField $leadField, ?int $userId): void
{
try {
$user = $this->getUser($userId);
} catch (NoUserException) {
return;
}
$message = $this->translator->trans(
'mautic.lead.field.notification.cannot_be_updated_message',
['%label%' => $leadField->getLabel()]
);
$header = $this->translator->trans('mautic.lead.field.notification.cannot_be_updated_header');
$this->addToNotificationCenter($user, $message, $header);
}
private function addToNotificationCenter(User $user, string $message, string $header): void
{
$this->notificationModel->addNotification(
$message,
'info',
false,
$header,
'ri-layout-column-line',
null,
$user
);
}
/**
* @throws NoUserException
*/
private function getUser(?int $userId): User
{
if (!$userId || !$user = $this->userModel->getEntity($userId)) {
throw new NoUserException();
}
return $user;
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
class SchemaDefinition
{
/**
* Max length of VARCHAR fields.
* Fields: charLengthLimit.
*/
public const MAX_VARCHAR_LENGTH = 191;
/**
* Get the MySQL database type based on the field type
* Use a static function so that it's accessible from DoctrineSubscriber
* without causing a circular service injection error.
*/
public static function getSchemaDefinition(string $alias, string $type, bool $isUnique = false, ?int $length = null): array
{
$options = ['notnull' => false];
// Unique is always a string in order to control index length
if ($isUnique) {
return [
'name' => $alias,
'type' => 'string',
'options' => $options,
];
}
switch ($type) {
case 'datetime':
case 'date':
case 'time':
case 'boolean':
$schemaType = $type;
break;
case 'number':
$schemaType = 'float';
break;
case 'timezone':
case 'locale':
case 'country':
case 'email':
case 'lookup':
case 'select':
case 'region':
case 'tel':
case 'url':
$schemaType = 'string';
$options['length'] = $length ?: self::MAX_VARCHAR_LENGTH;
break;
case 'text':
$schemaType = (str_contains($alias, 'description')) ? 'text' : 'string';
$options['length'] = $length;
break;
case 'multiselect':
$schemaType = 'text';
$options['length'] = 65535;
break;
case 'html':
default:
$schemaType = 'text';
}
if ('string' === $schemaType && empty($options['length'])) {
$options['length'] = self::MAX_VARCHAR_LENGTH;
}
return [
'name' => $alias,
'type' => $schemaType,
'options' => $options,
];
}
/**
* @param mixed[] $schemaDefinition
*/
public static function getFieldCharLengthLimit(array $schemaDefinition): ?int
{
$length = $schemaDefinition['options']['length'] ?? null;
$type = $schemaDefinition['type'] ?? null;
return match ($type) {
'string' => $length ?? ClassMetadataBuilder::MAX_VARCHAR_INDEXED_LENGTH,
'text' => $length,
default => null,
};
}
/**
* Get the MySQL database type based on the field type.
*/
public function getSchemaDefinitionNonStatic(string $alias, string $type, bool $isUnique = false, ?int $length = null): array
{
return self::getSchemaDefinition($alias, $type, $isUnique, $length);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Field\Settings;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
class BackgroundSettings
{
public const CREATE_CUSTOM_FIELD_IN_BACKGROUND = 'create_custom_field_in_background';
public function __construct(
private CoreParametersHelper $coreParametersHelper,
) {
}
public function shouldProcessColumnChangeInBackground(): bool
{
return (bool) $this->coreParametersHelper->get(self::CREATE_CUSTOM_FIELD_IN_BACKGROUND, false);
}
}