Skip to content

Commit 15a3af3

Browse files
authored
Merge pull request #1567 from algolia/epic/MAGE-721
MAGE-721 (for MAGE-959) Patches / CLIs for release 3.14.0-beta.2
2 parents 5f0dfb8 + 6510216 commit 15a3af3

22 files changed

+1328
-85
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace Algolia\AlgoliaSearch\Api\Console;
4+
5+
interface ReplicaDeleteCommandInterface
6+
{
7+
public function deleteReplicas(array $storeIds = [], bool $unused = false): void;
8+
public function deleteReplicasForStore(int $storeId, bool $unused = false): void;
9+
public function deleteReplicasForAllStores(bool $unused = false): void;
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace Algolia\AlgoliaSearch\Api\Console;
4+
5+
interface ReplicaSyncCommandInterface
6+
{
7+
public function syncReplicas(array $storeIds = []): void;
8+
public function syncReplicasForStore(int $storeId): void;
9+
public function syncReplicasForAllStores(): void;
10+
}

Api/Product/ReplicaManagerInterface.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,20 @@ interface ReplicaManagerInterface
2525
* @throws AlgoliaException
2626
* @throws ExceededRetriesException
2727
* @throws LocalizedException
28-
* @throws NoSuchEntityException
2928
*/
3029
public function syncReplicasToAlgolia(int $storeId, array $primaryIndexSettings): void;
3130

31+
/**
32+
* Delete the replica indices on a store index
33+
* @param int $storeId
34+
* @param bool $unused Defaults to false - if true identifies any straggler indices and deletes those, otherwise deletes the replicas it knows aobut
35+
* @return void
36+
*
37+
* @throws LocalizedException
38+
* @throws AlgoliaException
39+
*/
40+
public function deleteReplicasFromAlgolia(int $storeId, bool $unused = false): void;
41+
3242
/**
3343
* For standard Magento front end (e.g. Luma) replicas will likely only be needed if InstantSearch is enabled
3444
* Headless implementations may wish to override this behavior via plugin
@@ -44,4 +54,14 @@ public function isReplicaSyncEnabled(int $storeId): bool;
4454
* @return int
4555
*/
4656
public function getMaxVirtualReplicasPerIndex() : int;
57+
58+
/**
59+
* For a given store return replicas that do not appear to be managed by Magento
60+
* @param int $storeId
61+
* @return string[]
62+
* @throws NoSuchEntityException
63+
* @throws LocalizedException
64+
* @throws AlgoliaException
65+
*/
66+
public function getUnusedReplicaIndices(int $storeId): array;
4767
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<?php
2+
3+
namespace Algolia\AlgoliaSearch\Console\Command;
4+
5+
use Algolia\AlgoliaSearch\Service\StoreNameFetcher;
6+
use Magento\Framework\App\Area;
7+
use Magento\Framework\App\State;
8+
use Magento\Framework\Exception\LocalizedException;
9+
use Symfony\Component\Console\Command\Command;
10+
use Symfony\Component\Console\Input\InputArgument;
11+
use Symfony\Component\Console\Input\InputInterface;
12+
use Symfony\Component\Console\Output\OutputInterface;
13+
use Symfony\Component\Console\Question\ConfirmationQuestion;
14+
15+
abstract class AbstractReplicaCommand extends Command
16+
{
17+
protected const STORE_ARGUMENT = 'store';
18+
19+
protected ?OutputInterface $output = null;
20+
protected ?InputInterface $input = null;
21+
22+
public function __construct(
23+
protected State $state,
24+
protected StoreNameFetcher $storeNameFetcher,
25+
?string $name = null
26+
)
27+
{
28+
parent::__construct($name);
29+
}
30+
31+
abstract protected function getReplicaCommandName(): string;
32+
33+
abstract protected function getCommandDescription(): string;
34+
35+
abstract protected function getStoreArgumentDescription(): string;
36+
37+
abstract protected function getAdditionalDefinition(): array;
38+
39+
/**
40+
* @inheritDoc
41+
*/
42+
protected function configure(): void
43+
{
44+
$definition = [$this->getStoreArgumentDefinition()];
45+
$definition = array_merge($definition, $this->getAdditionalDefinition());
46+
47+
$this->setName($this->getCommandName())
48+
->setDescription($this->getCommandDescription())
49+
->setDefinition($definition);
50+
51+
parent::configure();
52+
}
53+
54+
protected function getStoreArgumentDefinition(): InputArgument {
55+
return new InputArgument(
56+
self::STORE_ARGUMENT,
57+
InputArgument::OPTIONAL | InputArgument::IS_ARRAY,
58+
$this->getStoreArgumentDescription()
59+
);
60+
}
61+
62+
public function getCommandName(): string
63+
{
64+
return 'algolia:replicas:' . $this->getReplicaCommandName();
65+
}
66+
67+
protected function setAreaCode(): void
68+
{
69+
try {
70+
$this->state->setAreaCode(Area::AREA_CRONTAB);
71+
} catch (LocalizedException) {
72+
// Area code is already set - nothing to do
73+
}
74+
}
75+
76+
/**
77+
* @param InputInterface $input
78+
* @return int[]
79+
*/
80+
protected function getStoreIds(InputInterface $input): array
81+
{
82+
return (array) $input->getArgument(self::STORE_ARGUMENT);
83+
}
84+
85+
/**
86+
* @param int[] $storeIds
87+
* @return string
88+
*/
89+
protected function getOperationTargetLabel(array $storeIds): string
90+
{
91+
return ($storeIds ? count($storeIds) : 'all') . ' store' . (!$storeIds || count($storeIds) > 1 ? 's' : '');
92+
}
93+
94+
/**
95+
* Generate a CLI operation announcement based on passed store arguments
96+
* @param string $msg Use {{target} in message as a placeholder for inserting the generated target label
97+
* @param int[] $storeIds
98+
* @return string
99+
*/
100+
protected function decorateOperationAnnouncementMessage(string $msg, array $storeIds): string
101+
{
102+
$msg = str_replace('{{target}}', $this->getOperationTargetLabel($storeIds), $msg);
103+
return ($storeIds)
104+
? "<info>$msg: " . join(", ", $this->storeNameFetcher->getStoreNames($storeIds)) . '</info>'
105+
: "<info>$msg</info>";
106+
}
107+
108+
protected function confirmOperation(string $okMessage = '', string $cancelMessage = 'Operation cancelled'): bool
109+
{
110+
$helper = $this->getHelper('question');
111+
$question = new ConfirmationQuestion('<question>Are you sure wish to proceed? (y/n)</question> ', false);
112+
if (!$helper->ask($this->input, $this->output, $question)) {
113+
if ($cancelMessage) {
114+
$this->output->writeln("<comment>$cancelMessage</comment>");
115+
}
116+
return false;
117+
}
118+
119+
if ($okMessage) {
120+
$this->output->writeln("<comment>$okMessage</comment>");
121+
}
122+
return true;
123+
}
124+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
<?php
2+
3+
namespace Algolia\AlgoliaSearch\Console\Command;
4+
5+
use Algolia\AlgoliaSearch\Api\Console\ReplicaDeleteCommandInterface;
6+
use Algolia\AlgoliaSearch\Api\Product\ReplicaManagerInterface;
7+
use Algolia\AlgoliaSearch\Console\Traits\ReplicaDeleteCommandTrait;
8+
use Algolia\AlgoliaSearch\Exceptions\BadRequestException;
9+
use Algolia\AlgoliaSearch\Service\StoreNameFetcher;
10+
use Magento\Framework\App\State;
11+
use Magento\Framework\Console\Cli;
12+
use Magento\Store\Model\StoreManagerInterface;
13+
use Symfony\Component\Console\Input\InputInterface;
14+
use Symfony\Component\Console\Input\InputOption;
15+
use Symfony\Component\Console\Output\OutputInterface;
16+
use Symfony\Component\Console\Question\ConfirmationQuestion;
17+
18+
class ReplicaDeleteCommand extends AbstractReplicaCommand implements ReplicaDeleteCommandInterface
19+
{
20+
use ReplicaDeleteCommandTrait;
21+
22+
protected const UNUSED_OPTION = 'unused';
23+
protected const UNUSED_OPTION_SHORTCUT = 'u';
24+
25+
public function __construct(
26+
protected ReplicaManagerInterface $replicaManager,
27+
protected StoreManagerInterface $storeManager,
28+
State $state,
29+
StoreNameFetcher $storeNameFetcher,
30+
?string $name = null
31+
)
32+
{
33+
parent::__construct($state, $storeNameFetcher, $name);
34+
}
35+
36+
protected function getReplicaCommandName(): string
37+
{
38+
return 'delete';
39+
}
40+
41+
protected function getCommandDescription(): string
42+
{
43+
return 'Delete associated replica indices in Algolia';
44+
}
45+
46+
protected function getStoreArgumentDescription(): string
47+
{
48+
return 'ID(s) for store(s) to delete replicas in Algolia (optional), if not specified, replicas for all stores will be deleted';
49+
}
50+
51+
protected function getAdditionalDefinition(): array
52+
{
53+
return [
54+
new InputOption(
55+
self::UNUSED_OPTION,
56+
'-' . self::UNUSED_OPTION_SHORTCUT,
57+
InputOption::VALUE_NONE,
58+
'Delete unused replicas only'
59+
)
60+
];
61+
}
62+
63+
protected function execute(InputInterface $input, OutputInterface $output): int
64+
{
65+
$this->output = $output;
66+
$this->input = $input;
67+
68+
$storeIds = $this->getStoreIds($input);
69+
$unused = $input->getOption(self::UNUSED_OPTION);
70+
71+
$output->writeln(
72+
$this->decorateOperationAnnouncementMessage(
73+
'Deleting' . ($unused ? ' unused ' : ' ') . 'replicas for {{target}}',
74+
$storeIds
75+
)
76+
);
77+
78+
if ($unused) {
79+
$unusedReplicas = $this->getUnusedReplicas($storeIds);
80+
if (!$unusedReplicas) {
81+
$output->writeln('<comment>No unused replicas found.</comment>');
82+
return Cli::RETURN_SUCCESS;
83+
}
84+
if (!$this->confirmDeleteUnused($unusedReplicas)) {
85+
return Cli::RETURN_SUCCESS;
86+
}
87+
} else if (!$this->confirmDelete()) {
88+
return Cli::RETURN_SUCCESS;
89+
}
90+
91+
try {
92+
$this->deleteReplicas($storeIds, $unused);
93+
} catch (BadRequestException $e) {
94+
$this->output->writeln("<error>Error encountered while attempting to delete replica: {$e->getMessage()}</error>");
95+
$this->output->writeln('<comment>It is likely that the Magento integration does not manage the index. You should review your application configuration in Algolia.</comment>');
96+
return CLI::RETURN_FAILURE;
97+
}
98+
99+
return Cli::RETURN_SUCCESS;
100+
}
101+
102+
/**
103+
* @param int[] $storeIds
104+
* @return string[]
105+
*/
106+
protected function getUnusedReplicas(array $storeIds): array
107+
{
108+
return array_reduce(
109+
$storeIds,
110+
function ($allUnused, $storeId) {
111+
$unused = [];
112+
try {
113+
$unused = $this->replicaManager->getUnusedReplicaIndices($storeId);
114+
} catch (\Exception $e) {
115+
$this->output->writeln("<error>Unable to retrieve unused replicas for $storeId: {$e->getMessage()}</error>");
116+
}
117+
return array_unique(array_merge($allUnused, $unused));
118+
},
119+
[]
120+
);
121+
}
122+
123+
/**
124+
* Deleting unused replica indices is potentially risky, especially if they have enabled query suggestions on their index
125+
* Verify with the end user first!
126+
*
127+
* @param string[] $unusedReplicas
128+
* @return bool
129+
*/
130+
protected function confirmDeleteUnused(array $unusedReplicas): bool
131+
{
132+
$this->output->writeln('<info>The following replicas appear to be unused and will be deleted:</info>');
133+
foreach ($unusedReplicas as $unusedReplica) {
134+
$this->output->writeln('<info> - ' . $unusedReplica . '</info>');
135+
}
136+
$helper = $this->getHelper('question');
137+
$question = new ConfirmationQuestion('<question>Do you want to proceed? (y/n)</question> ', false);
138+
139+
if (!$helper->ask($this->input, $this->output, $question)) {
140+
$this->output->writeln('<comment>Operation cancelled.</comment>');
141+
return false;
142+
}
143+
return true;
144+
}
145+
146+
protected function confirmDelete(): bool
147+
{
148+
$okMsg = 'Please note that you can restore these deleted replicas by running "algolia:replicas:sync".';
149+
return $this->confirmOperation($okMsg);
150+
}
151+
152+
}

0 commit comments

Comments
 (0)