SsrPurgeTargetResolver finds the paths of a saved VITEC or news record: its detail URL, the pages that select it (sys_refindex), product lists by category or all products, list plugins of its type and the records embedding it one level deep. URL, visibility and timing changes, the newest news, events, unknown tables and more than 150 paths still purge everything. Megamenu records and the megamenu element now purge everything (before: only its storage page). New read-only command vitec:ssr-purge-targets shows what a save would purge. Reference index rebuilt (117 updates). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
297 lines
13 KiB
PHP
297 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Evomedien\Vitec\Service;
|
|
|
|
use TYPO3\CMS\Core\Database\Connection;
|
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
|
use TYPO3\CMS\Core\Site\SiteFinder;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
|
|
/**
|
|
* Targeted auto-purge (2026-11-12, Harry): which SSR paths show a given
|
|
* VITEC or news record, so saving one product no longer empties the
|
|
* whole SSR cache.
|
|
*
|
|
* A record shows up on
|
|
* 1. its own detail URL (route enhancers in config/sites/vitec/config.yaml),
|
|
* 2. pages whose content elements select it explicitly (sys_refindex),
|
|
* 3. pages with list plugins that pick records of its type on their own,
|
|
* 4. the pages of records that embed it (related products, product
|
|
* downloads, product solutions) - one level deep.
|
|
* Everything still goes ('*') when the change can touch links or menus on
|
|
* every page: URL, visibility or timing fields, the newest news and the
|
|
* events (the megamenu spotlight shows them live), unknown tables, or
|
|
* more than MAX_PATHS paths. When in doubt: '*'.
|
|
*/
|
|
final class SsrPurgeTargetResolver
|
|
{
|
|
public const ALL = '*';
|
|
private const MAX_PATHS = 150;
|
|
|
|
/** Changing one of these can change URLs, menus or visibility everywhere. */
|
|
private const GLOBAL_FIELDS = ['slug', 'path_segment', 'hidden', 'deleted', 'starttime', 'endtime',
|
|
'fe_group', 'pid', 'sys_language_uid', 'type', 'datetime', 'is_group'];
|
|
|
|
/** Backend-only tables: nothing of them is rendered. */
|
|
private const INTERNAL_TABLES = ['tx_vitec_form_submission', 'tx_vitec_import_mapping', 'tx_vitec_seo_research',
|
|
'tx_vitec_product_workbook', 'tx_vitec_ssrpurgelog', 'tx_vitec_ssrhealthlog', 'tx_vitec_clienterrorlog'];
|
|
|
|
/** Inline children: child table => [parent field, parent table]. */
|
|
private const CHILD_TABLES = [
|
|
'tx_vitec_product_related_text' => ['product_uid', 'tx_vitec_domain_model_product'],
|
|
'tx_vitec_product_resource' => ['product', 'tx_vitec_domain_model_product'],
|
|
'tx_vitec_product_feature' => ['product', 'tx_vitec_domain_model_product'],
|
|
'tx_news_domain_model_link' => ['parent', 'tx_news_domain_model_news'],
|
|
];
|
|
|
|
/** Shown in the megamenu or rarely changed and used broadly. */
|
|
private const ALWAYS_ALL_TABLES = ['tx_vitec_domain_model_event', 'tx_vitec_domain_model_searchcard',
|
|
'tx_news_domain_model_tag'];
|
|
|
|
/** List plugins that show records of a table without a fixed selection. */
|
|
private const LIST_CTYPES = [
|
|
// Product lists (vitec_productlist) filter by category: productListPages().
|
|
'tx_vitec_domain_model_product' => ['vitec_productcheck'],
|
|
'tx_vitec_domain_model_usecase' => ['vitec_usecaselist', 'vitec_usecaseshow'],
|
|
'tx_vitec_domain_model_solution' => ['vitec_solutionlist'],
|
|
'tx_vitec_domain_model_market' => ['vitec_marketlist', 'vitec_marketshow', 'vitec_usecaselist'],
|
|
'tx_vitec_domain_model_download' => ['vitec_downloadcardcollection'],
|
|
'tx_vitec_domain_model_location' => ['vitec_locationlist'],
|
|
'tx_vitec_domain_model_customer' => ['vitec_customerlogos'],
|
|
'tx_news_domain_model_news' => ['news_pi1', 'news_newsliststicky', 'news_newsdetail'],
|
|
];
|
|
|
|
/** Records that embed a record of the key table: [embedding table, field]. */
|
|
private const EMBEDDED_IN = [
|
|
'tx_vitec_domain_model_product' => [['tx_vitec_domain_model_product', 'relatedprodukt'],
|
|
['tx_vitec_product_related_text', 'related_uid']],
|
|
'tx_vitec_domain_model_download' => [['tx_vitec_domain_model_product', 'downloads']],
|
|
'tx_vitec_domain_model_solution' => [['tx_vitec_domain_model_product', 'solutions']],
|
|
'tx_vitec_domain_model_usecase' => [['tx_news_domain_model_news', 'vitec_canonical_usecase']],
|
|
];
|
|
|
|
/** Detail routes: table => [plugin namespace, controller, action, argument, detail page uids]. */
|
|
private const DETAIL_ROUTES = [
|
|
'tx_vitec_domain_model_product' => ['tx_vitec_productshow', 'Product', 'show', 'product', [10]],
|
|
'tx_vitec_domain_model_usecase' => ['tx_vitec_usecaseshow', 'Usecase', 'show', 'usecase', [4, 25, 26]],
|
|
'tx_news_domain_model_news' => ['tx_news_pi1', 'News', 'detail', 'news', [21]],
|
|
];
|
|
|
|
/** The megamenu spotlight shows the newest news (ProvideInsightsSpotlight, 3 + margin). */
|
|
private const SPOTLIGHT_NEWS = 5;
|
|
private const SPOTLIGHT_NEWS_PIDS = [11, 442];
|
|
|
|
/**
|
|
* @param string[] $changedFields field names of the save ([] = unknown)
|
|
* @return list<string> paths to purge, ['*'] for everything, [] for nothing visible
|
|
*/
|
|
public function resolve(string $table, int $uid, array $changedFields): array
|
|
{
|
|
try {
|
|
return $this->doResolve($table, $uid, $changedFields);
|
|
} catch (\Throwable) {
|
|
return [self::ALL];
|
|
}
|
|
}
|
|
|
|
private function doResolve(string $table, int $uid, array $changedFields): array
|
|
{
|
|
if (in_array($table, self::INTERNAL_TABLES, true)) {
|
|
return [];
|
|
}
|
|
if ($uid <= 0) {
|
|
return [self::ALL];
|
|
}
|
|
if (isset(self::CHILD_TABLES[$table])) {
|
|
[$field, $parentTable] = self::CHILD_TABLES[$table];
|
|
$parentUid = (int)$this->connection($table)->executeQuery(
|
|
'SELECT ' . $field . ' FROM ' . $table . ' WHERE uid = ?', [$uid]
|
|
)->fetchOne();
|
|
return $parentUid > 0 ? $this->doResolve($parentTable, $parentUid, []) : [self::ALL];
|
|
}
|
|
if (in_array($table, self::ALWAYS_ALL_TABLES, true) || !isset(self::LIST_CTYPES[$table])) {
|
|
return [self::ALL];
|
|
}
|
|
if (array_intersect($changedFields, self::GLOBAL_FIELDS) !== []) {
|
|
return [self::ALL];
|
|
}
|
|
if ($table === 'tx_news_domain_model_news' && $this->isSpotlightNews($uid)) {
|
|
return [self::ALL];
|
|
}
|
|
|
|
$paths = $this->pathsOf($table, $uid);
|
|
foreach (self::EMBEDDED_IN[$table] ?? [] as [$embeddingTable, $field]) {
|
|
foreach ($this->embeddingRecords($embeddingTable, $field, $table, $uid) as [$hostTable, $hostUid]) {
|
|
$paths = array_merge($paths, $this->pathsOf($hostTable, $hostUid));
|
|
}
|
|
}
|
|
foreach ($this->pagesWithCTypes(self::LIST_CTYPES[$table]) as $pid) {
|
|
$paths[] = $this->pagePath($pid);
|
|
}
|
|
if ($table === 'tx_vitec_domain_model_product') {
|
|
foreach ($this->productListPages($uid) as $pid) {
|
|
$paths[] = $this->pagePath($pid);
|
|
}
|
|
}
|
|
$paths = array_values(array_unique(array_filter($paths)));
|
|
return count($paths) > self::MAX_PATHS ? [self::ALL] : $paths;
|
|
}
|
|
|
|
/** Detail URL(s) plus the pages that select the record explicitly. */
|
|
private function pathsOf(string $table, int $uid): array
|
|
{
|
|
$paths = $this->detailPaths($table, $uid);
|
|
$pids = $this->connection('sys_refindex')->executeQuery(
|
|
'SELECT DISTINCT c.pid FROM sys_refindex r JOIN tt_content c ON c.uid = r.recuid
|
|
WHERE r.tablename = ? AND r.ref_table = ? AND r.ref_uid = ? AND c.deleted = 0',
|
|
['tt_content', $table, $uid]
|
|
)->fetchFirstColumn();
|
|
foreach ($pids as $pid) {
|
|
$paths[] = $this->pagePath((int)$pid);
|
|
}
|
|
if ($table === 'tx_vitec_domain_model_solution') {
|
|
$detailPage = (int)$this->connection($table)->executeQuery(
|
|
'SELECT detail_page FROM ' . $table . ' WHERE uid = ?', [$uid]
|
|
)->fetchOne();
|
|
if ($detailPage > 0) {
|
|
$paths[] = $this->pagePath($detailPage);
|
|
}
|
|
}
|
|
return $paths;
|
|
}
|
|
|
|
/** @return list<array{0: string, 1: int}> records embedding the given one */
|
|
private function embeddingRecords(string $embeddingTable, string $field, string $table, int $uid): array
|
|
{
|
|
if ($embeddingTable === 'tx_vitec_product_related_text') {
|
|
$rows = $this->connection($embeddingTable)->executeQuery(
|
|
'SELECT DISTINCT product_uid FROM tx_vitec_product_related_text WHERE related_uid = ?', [$uid]
|
|
)->fetchFirstColumn();
|
|
return array_map(static fn($u): array => ['tx_vitec_domain_model_product', (int)$u], $rows);
|
|
}
|
|
$rows = $this->connection('sys_refindex')->executeQuery(
|
|
'SELECT DISTINCT recuid FROM sys_refindex WHERE tablename = ? AND field = ? AND ref_table = ? AND ref_uid = ?',
|
|
[$embeddingTable, $field, $table, $uid]
|
|
)->fetchFirstColumn();
|
|
return array_map(static fn($u): array => [$embeddingTable, (int)$u], $rows);
|
|
}
|
|
|
|
private function detailPaths(string $table, int $uid): array
|
|
{
|
|
if (!isset(self::DETAIL_ROUTES[$table])) {
|
|
return [];
|
|
}
|
|
[$namespace, $controller, $action, $argument, $pageUids] = self::DETAIL_ROUTES[$table];
|
|
$paths = [];
|
|
foreach ($pageUids as $pageUid) {
|
|
try {
|
|
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageUid);
|
|
$paths[] = $site->getRouter()->generateUri($pageUid, [
|
|
$namespace => ['action' => $action, 'controller' => $controller, $argument => $uid],
|
|
])->getPath();
|
|
} catch (\Throwable) {
|
|
// Record not routable (hidden, no slug): its pages are covered by the lists.
|
|
}
|
|
}
|
|
return $paths;
|
|
}
|
|
|
|
private function pagesWithCTypes(array $cTypes): array
|
|
{
|
|
if ($cTypes === []) {
|
|
return [];
|
|
}
|
|
return array_map('intval', $this->connection('tt_content')->executeQuery(
|
|
'SELECT DISTINCT pid FROM tt_content WHERE deleted = 0 AND CType IN (?)',
|
|
[$cTypes],
|
|
[Connection::PARAM_STR_ARRAY]
|
|
)->fetchFirstColumn());
|
|
}
|
|
|
|
/**
|
|
* Pages with a product list that can show the product. Mirrors
|
|
* ProductListJsonRenderer: selected products (settings.selectedproducts)
|
|
* show exactly those - covered by the refindex; otherwise a category
|
|
* filter matches the selected categories and their whole subtree;
|
|
* "all products" or no category shows every product.
|
|
*/
|
|
private function productListPages(int $productUid): array
|
|
{
|
|
// The product's categories plus all their ancestors.
|
|
$categories = array_map('intval', $this->connection('sys_category_record_mm')->executeQuery(
|
|
"SELECT uid_local FROM sys_category_record_mm
|
|
WHERE tablenames = 'tx_vitec_domain_model_product' AND fieldname = 'categories' AND uid_foreign = ?",
|
|
[$productUid]
|
|
)->fetchFirstColumn());
|
|
$seen = [];
|
|
$queue = $categories;
|
|
while ($queue !== []) {
|
|
$cat = array_shift($queue);
|
|
if ($cat <= 0 || isset($seen[$cat])) {
|
|
continue;
|
|
}
|
|
$seen[$cat] = true;
|
|
$queue[] = (int)$this->connection('sys_category')->executeQuery(
|
|
'SELECT parent FROM sys_category WHERE uid = ?', [$cat]
|
|
)->fetchOne();
|
|
}
|
|
|
|
$pages = [];
|
|
$lists = $this->connection('tt_content')->executeQuery(
|
|
"SELECT uid, pid, pi_flexform FROM tt_content WHERE deleted = 0 AND CType = 'vitec_productlist'"
|
|
)->fetchAllAssociative();
|
|
foreach ($lists as $list) {
|
|
$refs = $this->connection('sys_refindex')->executeQuery(
|
|
"SELECT ref_table, ref_uid FROM sys_refindex
|
|
WHERE tablename = 'tt_content' AND recuid = ? AND field = 'pi_flexform'
|
|
AND ref_table IN ('tx_vitec_domain_model_product', 'sys_category')",
|
|
[(int)$list['uid']]
|
|
)->fetchAllAssociative();
|
|
$selected = array_filter($refs, static fn(array $r): bool => $r['ref_table'] === 'tx_vitec_domain_model_product');
|
|
if ($selected !== []) {
|
|
continue; // fixed selection: in the refindex pages already when it contains the product
|
|
}
|
|
$listCategories = array_map('intval', array_column(
|
|
array_filter($refs, static fn(array $r): bool => $r['ref_table'] === 'sys_category'),
|
|
'ref_uid'
|
|
));
|
|
$allProducts = preg_match('#settings\.allproducts">\s*<value index="vDEF">1<#', (string)$list['pi_flexform']) === 1;
|
|
if ($allProducts || $listCategories === [] || array_intersect($listCategories, array_keys($seen)) !== []) {
|
|
$pages[] = (int)$list['pid'];
|
|
}
|
|
}
|
|
return array_values(array_unique($pages));
|
|
}
|
|
|
|
private function isSpotlightNews(int $uid): bool
|
|
{
|
|
$newest = $this->connection('tx_news_domain_model_news')->executeQuery(
|
|
'SELECT uid FROM tx_news_domain_model_news WHERE deleted = 0 AND hidden = 0 AND type = 0 AND pid IN (?)
|
|
ORDER BY datetime DESC LIMIT ' . self::SPOTLIGHT_NEWS,
|
|
[self::SPOTLIGHT_NEWS_PIDS],
|
|
[Connection::PARAM_INT_ARRAY]
|
|
)->fetchFirstColumn();
|
|
return in_array($uid, array_map('intval', $newest), true);
|
|
}
|
|
|
|
private function pagePath(int $pageId): string
|
|
{
|
|
if ($pageId <= 0) {
|
|
return '';
|
|
}
|
|
try {
|
|
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageId);
|
|
return $site->getRouter()->generateUri($pageId)->getPath() ?: '/';
|
|
} catch (\Throwable) {
|
|
return ''; // Folder or page outside a site: nothing cached for it.
|
|
}
|
|
}
|
|
|
|
private function connection(string $table): Connection
|
|
{
|
|
return GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
|
|
}
|
|
}
|