first commit
This commit is contained in:
529
class/class.MenuSectionTree.php
Normal file
529
class/class.MenuSectionTree.php
Normal file
@@ -0,0 +1,529 @@
|
||||
<?php
|
||||
|
||||
use Doctrine\ORM\Cache\CacheException;
|
||||
use KupShop\KupShopBundle\Context\CacheContext;
|
||||
use KupShop\KupShopBundle\Context\ContextManager;
|
||||
use KupShop\KupShopBundle\Context\LanguageContext;
|
||||
use KupShop\KupShopBundle\Util\Compat\ServiceContainer;
|
||||
use KupShop\KupShopBundle\Util\Contexts;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Lock\Store\FlockStore;
|
||||
|
||||
class MenuSectionTree
|
||||
{
|
||||
/**
|
||||
* @var \KupShop\CatalogBundle\Entity\Section[]
|
||||
*/
|
||||
protected $cache;
|
||||
|
||||
protected $mapping = [];
|
||||
|
||||
protected ?int $timeCached = null;
|
||||
|
||||
protected $languageID;
|
||||
|
||||
/** @var LoggerInterface */
|
||||
protected $logger;
|
||||
|
||||
public const SECTIONS_LAST_UPDATED_KEY = 'sections-last-updated';
|
||||
|
||||
public function __construct($languageID)
|
||||
{
|
||||
$this->languageID = $languageID;
|
||||
}
|
||||
|
||||
public function updateSelection($selection)
|
||||
{
|
||||
$root = &$this->cache;
|
||||
$item = null;
|
||||
foreach ($selection as &$item_id) {
|
||||
if (!isset($root[$item_id])) {
|
||||
break;
|
||||
}
|
||||
|
||||
$item = &$root[$item_id];
|
||||
$root = &$item['submenu'];
|
||||
|
||||
$item['opened'] = true;
|
||||
}
|
||||
|
||||
if ($item) {
|
||||
$item['selected'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \KupShop\CatalogBundle\Entity\Section[]
|
||||
*/
|
||||
protected function createTreeMenuProducts($rootSection = 0)
|
||||
{
|
||||
$retTree = [];
|
||||
|
||||
$where = ($rootSection) ? 'S1.id = :rootSection' : 'SR1.id_topsection IS NULL';
|
||||
|
||||
$recursiveQuery = "
|
||||
WITH RECURSIVE cte (level, id, name, id_topsection, position) AS (
|
||||
SELECT 0, S1.id, S1.name, SR1.id_topsection, SR1.position
|
||||
FROM sections S1
|
||||
LEFT JOIN sections_relation SR1 on (S1.id = SR1.id_section)
|
||||
WHERE {$where}
|
||||
UNION ALL
|
||||
SELECT cte.level + 1, S2.id, S2.name, SR2.id_topsection, SR2.position
|
||||
FROM sections S2
|
||||
INNER JOIN sections_relation SR2 on S2.id = SR2.id_section
|
||||
INNER JOIN cte ON (cte.id = SR2.id_topsection)
|
||||
WHERE cte.level < 50
|
||||
)
|
||||
SELECT * FROM cte
|
||||
";
|
||||
|
||||
$defaultFilterParams = (new ProductList())->applydefaultFilterParams();
|
||||
|
||||
$productsSubQuery = sqlQueryBuilder()
|
||||
// Počítání produktů v sekcích - Kvůli přesnosti počtů produktů se musí používat 'applydefaultFilterParams()'.
|
||||
// To na sebe může joinout 1:n tabulky, takže se to musí groupnout podle 'p.id' a agregovat až ve vnější query.
|
||||
// Alternativní možnost je joinout 'products_in_sections' a agregovat to už tady,
|
||||
// ale kvůli možným 1:n joinům by se musel vytvořit další subquery, což už by byly 3 subquery
|
||||
// v sobě a rychlostně to skončí podobně.
|
||||
->select('p.id, p.in_store')
|
||||
->from('products', 'p')
|
||||
->joinProducersOnProducts()
|
||||
->andWhere($defaultFilterParams->getSpec())
|
||||
->groupBy('p.id');
|
||||
|
||||
$qb = sqlQueryBuilder()->select('sr.level, sr.id_topsection, s.*')
|
||||
->from('('.$recursiveQuery.')', 'sr')
|
||||
->innerJoin('sr', 'sections', 's', 's.id = sr.id')
|
||||
->leftJoin('s', 'products_in_sections', 'ps', 's.id = ps.id_section')
|
||||
->leftJoinSubQuery('ps', $productsSubQuery, 'p', 'ps.id_product = p.id')
|
||||
->addSelect('COUNT(p.id) count, SUM(IF(p.in_store > 0, 1, 0)) AS in_stock_count')
|
||||
->andWhere(
|
||||
\Query\Translation::coalesceTranslatedFields(
|
||||
\KupShop\I18nBundle\Translations\SectionsTranslation::class,
|
||||
function ($columns) {
|
||||
$columns['data'] = 'data_translated';
|
||||
|
||||
return $columns;
|
||||
}
|
||||
)
|
||||
)
|
||||
->groupBy('s.id')
|
||||
->orderBy('sr.level, sr.position')->addOrderBy('s.name');
|
||||
|
||||
if ($rootSection) {
|
||||
$qb->setParameter('rootSection', $rootSection);
|
||||
}
|
||||
|
||||
if (findModule(\Modules::PRODUCTS_SUPPLIERS)) {
|
||||
$productsSuppliersSubQuery = sqlQueryBuilder()
|
||||
->select('ppos.id_product, SUM(IF(ppos.in_store > 0, 1, 0)) count')
|
||||
->from('products_of_suppliers', 'ppos')
|
||||
->groupBy('ppos.id_product');
|
||||
|
||||
$qb->leftJoinSubQuery('p', $productsSuppliersSubQuery, 'pos', 'pos.id_product = p.id');
|
||||
|
||||
$qb->addSelect('SUM(COALESCE((pos.count),0)) AS in_stock_suppliers_count');
|
||||
}
|
||||
|
||||
$SQL = $qb->execute()->fetchAll();
|
||||
|
||||
$productsFilterSpecs = ServiceContainer::getService(\KupShop\CatalogBundle\Util\ProductsFilterSpecs::class);
|
||||
|
||||
foreach ($SQL as $row) {
|
||||
unset($section);
|
||||
|
||||
$sectionData = array_replace_recursive(json_decode($row['data'] ?? '{}', true) ?: [], json_decode($row['data_translated'] ?? '{}', true) ?: []);
|
||||
/* @var $parent \KupShop\CatalogBundle\Entity\Section */
|
||||
|
||||
$parent = ($row['id_topsection']) ? $this->mapping[$row['id_topsection']] ?? null : null;
|
||||
|
||||
// skip section when parent is not found, but id_topsection is set
|
||||
// for example when hide_not_translated_objects is enabled for sections and parent section is not translated
|
||||
// the child section is passed to section tree as root section, and it is invalid behaviour!
|
||||
if (!$parent && $row['id_topsection']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$section = new \KupShop\CatalogBundle\Entity\Section();
|
||||
$section->setId($row['id'])
|
||||
->setIdBlock($row['id_block'])
|
||||
->setName($row['name'])
|
||||
->setNameShort($row['name_short'] ?? $row['name'])
|
||||
->setFigure($row['figure'])
|
||||
->setShowInSearch($row['show_in_search'])
|
||||
->setBehaviour($row['behaviour'])
|
||||
->setOrderby($row['orderby'])
|
||||
->setOrderdir($row['orderdir'])
|
||||
->setDateUpdated(new DateTime($row['date_updated']))
|
||||
->setListVariations(findModule(\Modules::PRODUCTS_VARIATIONS) ? $row['list_variations'] : 'N')
|
||||
->setLeadFigure($row['lead_figure'])
|
||||
->setLeadText($row['lead_text'])
|
||||
->setLeadProducts($row['lead_products'])
|
||||
->setPhoto($row['photo'])
|
||||
->setMetaTitle($row['meta_title'])
|
||||
->setMetaDescription($row['meta_description'])
|
||||
->setMetaKeywords($row['meta_keywords'])
|
||||
->setFeedHeureka($row['feed_heureka'])
|
||||
->setFeedGoogle($row['feed_google'])
|
||||
->setFeedHeurekaSk($row['feed_heureka_sk'])
|
||||
->setFeedGlami($row['feed_glami'] ?? null)
|
||||
->setIdSlider($row['id_slider'] ?? null)
|
||||
->setFlags(!$row['flags'] ? [] : explodeFlags($row['flags']))
|
||||
->setFeedSeznam($row['feed_seznam'])
|
||||
->setProducersFilter($row['producers_filter'])
|
||||
->setParent($parent)
|
||||
->setData($sectionData)
|
||||
->setVirtual($row['virtual'])
|
||||
->setCount($row['count'])
|
||||
->setAnnotation($row['annotation'] ?? null)
|
||||
->setRedirectUrl($row['redirect_url'])
|
||||
->setPriority($row['priority']);
|
||||
|
||||
$inStockCount = $row['in_stock_count'];
|
||||
if (findModule(\Modules::PRODUCTS_SUPPLIERS)) {
|
||||
$inStockCount += (int) $row['in_stock_suppliers_count'];
|
||||
}
|
||||
$section->setInStockCount($inStockCount);
|
||||
|
||||
if ($section->getId() === 0 && empty($row['url'])) {
|
||||
$section->setUrl(trim(path('kupshop_catalog_catalog_category'), '/'));
|
||||
} elseif (findModule('products_sections', 'custom_url')) {
|
||||
$section->setUrl($row['url']);
|
||||
}
|
||||
|
||||
// nacteni poctu produktu ve virtualni sekci, ale pouze pokud nejsou zapnuty materializovany virt. sekce
|
||||
if (!findModule(Modules::PRODUCTS_SECTIONS, Modules::SUB_VIRTUAL_TO_DB) && $section->isVirtual()) {
|
||||
$virtualSettings = $section->getData()['virtual_settings'] ?? [];
|
||||
$virtualCount = sqlQueryBuilder()->select('COUNT(DISTINCT p.id) as count')
|
||||
->from('products', 'p')
|
||||
->andWhere($productsFilterSpecs->getSpecs($virtualSettings))
|
||||
->andWhere($defaultFilterParams->getSpec())
|
||||
->execute()->fetchColumn();
|
||||
|
||||
$section->setCount($virtualCount);
|
||||
$section->setInStockCount($virtualCount);
|
||||
}
|
||||
|
||||
$this->mapping[$section->getId()] = &$section;
|
||||
|
||||
if ($parent && $section->getId() != $rootSection) {
|
||||
$siblings = $parent->getChildren();
|
||||
$siblings->set($section->getId(), $section);
|
||||
$parent->setChildren($siblings);
|
||||
} elseif ($section->getId() > 0) {
|
||||
$retTree[$section->getId()] = $section;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_reverse($this->mapping) as &$section) {
|
||||
if ($section->getBehaviour() != 1) {
|
||||
$count = $section->getCount();
|
||||
$inStockCount = $section->getInStockCount();
|
||||
|
||||
foreach ($section->getChildren() as $child) {
|
||||
/* @var $child \KupShop\CatalogBundle\Entity\Section */
|
||||
$count += $child->getCount();
|
||||
$inStockCount += $child->getInStockCount();
|
||||
}
|
||||
$section->setCount($count);
|
||||
$section->setInStockCount($inStockCount);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($this->mapping[0])) {
|
||||
$section0 = $this->mapping[0];
|
||||
|
||||
// vytvoreni podsekci
|
||||
$section0->setChildren(new \Doctrine\Common\Collections\ArrayCollection($retTree));
|
||||
|
||||
// calc products count
|
||||
$count = $section0->getCount();
|
||||
$inStockCount = $section0->getInStockCount();
|
||||
foreach ($section0->getChildren() as $child) {
|
||||
/* @var $child \KupShop\CatalogBundle\Entity\Section */
|
||||
$count += $child->getCount();
|
||||
$inStockCount += $child->getInStockCount();
|
||||
}
|
||||
$section0->setCount($count);
|
||||
$section0->setInStockCount($inStockCount);
|
||||
}
|
||||
|
||||
return $retTree;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \KupShop\CatalogBundle\Entity\Section|null
|
||||
*/
|
||||
public function getSectionById($id)
|
||||
{
|
||||
$this->initializeTree();
|
||||
|
||||
if (!empty($this->mapping[$id])) {
|
||||
return $this->mapping[$id];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $glue
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getFullPath($id, $glue = '/')
|
||||
{
|
||||
$this->initializeTree();
|
||||
|
||||
if (!empty($this->mapping[$id])) {
|
||||
return join($glue, $this->mapping[$id]['parents']);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function &getTree()
|
||||
{
|
||||
$this->initializeTree();
|
||||
|
||||
return $this->cache;
|
||||
}
|
||||
|
||||
public function getTimeCached(): ?int
|
||||
{
|
||||
$this->initializeTree();
|
||||
|
||||
return $this->timeCached;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CacheException
|
||||
*/
|
||||
public function initializeTree()
|
||||
{
|
||||
if (!$this->cache) {
|
||||
$cacheContext = Contexts::get(CacheContext::class);
|
||||
$contextManager = ServiceContainer::getService(ContextManager::class);
|
||||
|
||||
$contextManager->activateContexts([LanguageContext::class => $this->languageID], function () use ($cacheContext) {
|
||||
$cacheKey = implode('-', [
|
||||
'sections',
|
||||
$cacheContext->getKey([CacheContext::TYPE_TEXT, CacheContext::TYPE_AVAILABILITY]),
|
||||
]);
|
||||
|
||||
// First try local cache
|
||||
// Administrators do not use local cache - problem with local cache cleaning in multiple shop instances
|
||||
$isLocalFresh = null;
|
||||
$localData = null;
|
||||
if (!getAdminUser() && $localData = $this->getLocalCache($cacheKey, $isLocalFresh)) {
|
||||
// If local cache is young enough, use it
|
||||
if ($isLocalFresh) {
|
||||
$this->useCache($localData);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Temporary hack for OOM on menu load. Increase whenever we load second menu from memcached
|
||||
increaseMemoryLimit(60);
|
||||
|
||||
$remoteData = $this->getRemoteCache($cacheKey);
|
||||
|
||||
// Then try remote
|
||||
if ($remoteData && !$this->isCacheInvalidated($remoteData['timeCached'] ?? null)) {
|
||||
// Update/Create local cache
|
||||
$this->setLocalCache($cacheKey, $remoteData);
|
||||
|
||||
$this->useCache($remoteData);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Budu načítat a ukládat menu. Zvětším paměť, protože APC store, memcached store a další žerou hodně paměti
|
||||
// TODO: Tohle si můžu dovolit, protože sem bych se neměl moc často dostat, cache je validní dlouho
|
||||
increaseMemoryLimit(100);
|
||||
|
||||
$debugFirstRemoteData = (bool) $remoteData;
|
||||
|
||||
// Use local blocking store - better to not rely on any remote locks store
|
||||
$store = new FlockStore();
|
||||
$factory = new LockFactory($store);
|
||||
// lock releases after 90s
|
||||
$lock = $factory->createLock('lock-'.$cacheKey, 90);
|
||||
|
||||
// Pokud máme lokální/remote cache, nemá cenu stát na locku. Použiju lokální/remote, i když není fresh a spustím async job na vygenerování sekcí
|
||||
// Když nemáme lokální/remote cache, musím čekat :-(
|
||||
$SYNC_JOB = !$localData && !$remoteData;
|
||||
$debugSecondRemoteData = null;
|
||||
$debugSetRemoteData = null;
|
||||
|
||||
$jobId = md5(time().rand());
|
||||
|
||||
if ($SYNC_JOB) { // no cache is available, generate it synchronously or wait for the result
|
||||
$this->kibanaLog('MenuSectionTree: blocking lock - old cache not available', ['job' => $jobId]);
|
||||
|
||||
if ($lock->acquire(blocking: true)) {
|
||||
$debugAcquired = true;
|
||||
$remoteData = $this->getRemoteCache($cacheKey);
|
||||
$debugSecondRemoteData = (bool) $remoteData;
|
||||
if (!$remoteData || $this->isCacheInvalidated($remoteData['timeCached'] ?? null)) {
|
||||
$this->kibanaLog('MenuSectionTree: generating tree SYNC '.$this->languageID, ['job' => $jobId]);
|
||||
$this->generateSectionsTreeJob($cacheKey);
|
||||
$debugSetRemoteData = 'sync';
|
||||
} else {
|
||||
// cache has been already updated while waiting
|
||||
$this->useCache($remoteData);
|
||||
}
|
||||
$lock->release();
|
||||
} else {
|
||||
throw new \LogicException('MenuSectionTree - Blocking acquire should never return false!');
|
||||
}
|
||||
} else { // Schedule an async job to generate the tree. Use old cache in the meantime
|
||||
$asyncQueue = ServiceContainer::getService(\KupShop\KupShopBundle\Util\System\AsyncQueue::class);
|
||||
$asyncQueue->addHandler(function () use ($cacheKey, $lock, $jobId) {
|
||||
// blocking lock should not be needed. If the lock is already acquired, the tree is already being generated and is no need to wait for anything
|
||||
if ($lock->acquire(blocking: false)) {
|
||||
$this->kibanaLog('MenuSectionTree: generating tree ASYNC '.$this->languageID, ['job' => $jobId]);
|
||||
$this->generateSectionsTreeAsyncJob($cacheKey);
|
||||
$lock->release();
|
||||
}
|
||||
}, "SECTIONS - generate menuSectionTree ({$cacheKey})");
|
||||
|
||||
if (!$localData && !$remoteData) {
|
||||
throw new \LogicException('MenuSectionTree - When non-blocking acquire fails, local or remote cache should be always available!');
|
||||
}
|
||||
|
||||
$debugSetRemoteData = 'async';
|
||||
$this->useCache($localData ?: $remoteData);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected function generateSectionsTreeAsyncJob(string $cacheKey)
|
||||
{
|
||||
$contextManager = ServiceContainer::getService(ContextManager::class);
|
||||
|
||||
return $contextManager->activateContexts([LanguageContext::class => $this->languageID], function () use ($cacheKey) {
|
||||
return $this->generateSectionsTreeJob($cacheKey);
|
||||
});
|
||||
}
|
||||
|
||||
protected function generateSectionsTreeJob(string $cacheKey)
|
||||
{
|
||||
$this->timeCached = time();
|
||||
$this->cache = $this->createTreeMenuProducts(0);
|
||||
$remoteData = [
|
||||
'cache' => $this->cache,
|
||||
'mapping' => $this->mapping ?? [],
|
||||
'timeCached' => $this->timeCached,
|
||||
];
|
||||
|
||||
setCache($cacheKey, $remoteData, 3600);
|
||||
|
||||
// update apcu - should be needed only after regeneration, otherwise all released processes would spam setLocalCache
|
||||
$this->setLocalCache($cacheKey, $remoteData);
|
||||
|
||||
return $remoteData;
|
||||
}
|
||||
|
||||
protected function getRemoteCache(string $fromKey)
|
||||
{
|
||||
return getCache($fromKey);
|
||||
}
|
||||
|
||||
protected function useCache(array $cache)
|
||||
{
|
||||
if (!empty($cache['cache'])) {
|
||||
$this->cache = $cache['cache'];
|
||||
}
|
||||
|
||||
if (!empty($cache['mapping'])) {
|
||||
$this->mapping = $cache['mapping'];
|
||||
}
|
||||
|
||||
$this->timeCached = $cache['timeCached'] ?? null;
|
||||
|
||||
return $this->cache;
|
||||
}
|
||||
|
||||
protected function getLocalCache(string $key, ?bool &$isFresh)
|
||||
{
|
||||
$cache = getLocalCache();
|
||||
|
||||
$item = $cache->getItem($key);
|
||||
|
||||
if ($item->isHit()) {
|
||||
$data = $item->get();
|
||||
$isFresh = (time() - $data['time']) <= 5;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
$isFresh = false;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// The only correct method for clearing sectionsCache
|
||||
// clearCache('sections', true) and other old ways are FORBIDDEN unless you know exactly what you are doing
|
||||
public static function invalidateCache(?string $languageID = null)
|
||||
{
|
||||
$now = time();
|
||||
setCache(self::SECTIONS_LAST_UPDATED_KEY.($languageID ? '-'.$languageID : ''), $now, 0);
|
||||
}
|
||||
|
||||
protected function isCacheInvalidated(?int $cacheUpdated)
|
||||
{
|
||||
$lastUpdated = $this->getSectionsLastUpdated();
|
||||
|
||||
if (is_null($lastUpdated)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_null($cacheUpdated)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $cacheUpdated < $lastUpdated;
|
||||
}
|
||||
|
||||
protected function getSectionsLastUpdated(): ?int
|
||||
{
|
||||
$lastUpdatedGlobal = getCache(self::SECTIONS_LAST_UPDATED_KEY);
|
||||
$lastUpdatedLang = getCache(self::SECTIONS_LAST_UPDATED_KEY.'-'.$this->languageID);
|
||||
|
||||
if ($lastUpdatedGlobal && $lastUpdatedLang) {
|
||||
return max($lastUpdatedGlobal, $lastUpdatedLang);
|
||||
}
|
||||
|
||||
return $lastUpdatedLang ?: $lastUpdatedGlobal ?: null;
|
||||
}
|
||||
|
||||
private function kibanaLog(string $message, array $data = []): void
|
||||
{
|
||||
if (!isProduction()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$logger = ServiceContainer::getService('logger');
|
||||
$logger->notice($message,
|
||||
array_merge(
|
||||
$data,
|
||||
[
|
||||
'languageID' => $this->languageID,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
protected function setLocalCache(string $key, array $data)
|
||||
{
|
||||
$cache = getLocalCache();
|
||||
|
||||
$item = $cache->getItem($key);
|
||||
$data['time'] = time();
|
||||
$item->set($data);
|
||||
|
||||
$cache->save($item);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user