From 4b74832b7acd1a01e37527d5fc7193088e4f41d4 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Sun, 16 Aug 2026 17:52:47 +0600 Subject: [PATCH] perf(web-api): batch-load product Data on catalog list Avoid per-row getOne(Data) on product/list, drop useless COUNT DISTINCT for the 1:1 Data join, and skip selecting content when include_content is off. --- .../Product/ProductCatalogService.php | 72 ++++++++++++++++--- .../tests/ProductCatalogPerfTest.php | 57 +++++++++++++++ 2 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 core/components/minishop3/tests/ProductCatalogPerfTest.php diff --git a/core/components/minishop3/src/Services/Product/ProductCatalogService.php b/core/components/minishop3/src/Services/Product/ProductCatalogService.php index 2a0276c9..ed0ddd69 100644 --- a/core/components/minishop3/src/Services/Product/ProductCatalogService.php +++ b/core/components/minishop3/src/Services/Product/ProductCatalogService.php @@ -211,20 +211,21 @@ public function getList(array $params): array $total = $this->countList($params); $listQuery = $this->buildListQuery($params); + $this->applyListSelect($listQuery, $includeContent); $this->applySort($listQuery, $params); $listQuery->limit($limit, $offset); - /** @var list $products */ + /** @var array $products */ $products = $this->modx->getCollection(msProduct::class, $listQuery) ?: []; + $productList = array_values($products); + $ids = $this->prefetchAndAttachProductData($productList); - $optionsByProduct = []; - if ($includeOptions && $products !== []) { - $ids = array_map(static fn (msProduct $p) => (int) $p->get('id'), array_values($products)); - $optionsByProduct = $this->loadOptionsForProducts($ids); - } + $optionsByProduct = ($includeOptions && $ids !== []) + ? $this->loadOptionsForProducts($ids) + : []; $items = []; - foreach ($products as $product) { + foreach ($productList as $product) { $productId = (int) $product->get('id'); $options = $includeOptions ? ($optionsByProduct[$productId] ?? []) : null; $items[] = $this->formatProduct($product, $includeContent, $options); @@ -269,7 +270,8 @@ private function resolveContext(array $params): string private function countList(array $params): int { $countQuery = $this->buildListQuery($params); - $countQuery->select('COUNT(DISTINCT msProduct.id)'); + // 1:1 join on Data — DISTINCT is unnecessary until many-joins are added. + $countQuery->select('COUNT(msProduct.id)'); if (!$countQuery->prepare() || !$countQuery->stmt->execute()) { return 0; } @@ -307,6 +309,60 @@ private function buildListQuery(array $params): xPDOQuery return $c; } + /** + * Limit selected resource columns; skip content blob on PLP when not requested. + * Same pattern as ms3_products snippet. + */ + private function applyListSelect(xPDOQuery $query, bool $includeContent): void + { + $query->select( + $includeContent + ? $this->modx->getSelectColumns(msProduct::class, 'msProduct') + : $this->modx->getSelectColumns(msProduct::class, 'msProduct', '', ['content'], true) + ); + } + + /** + * Batch-load msProductData and attach via addOne so loadData() skips getOne N+1. + * + * List query already JOINs Data for WHERE/ORDER only (no related hydrate from that JOIN). + * One IN-query here is O(1) vs L× getOne; total SQL ≈ count + list + data (+ options). + * + * @param list $products + * @return list + */ + private function prefetchAndAttachProductData(array $products): array + { + $byId = []; + foreach ($products as $product) { + $id = (int) $product->get('id'); + if ($id > 0) { + $byId[$id] = $product; + } + } + + if ($byId === []) { + return []; + } + + $ids = array_keys($byId); + $c = $this->modx->newQuery(msProductData::class); + $c->where(['id:IN' => $ids]); + + /** @var msProductData $data */ + foreach ($this->modx->getCollection(msProductData::class, $c) ?: [] as $data) { + $id = (int) $data->get('id'); + if (!isset($byId[$id])) { + continue; + } + // addOne requires a by-ref argument (xPDO signature). + $attached = $data; + $byId[$id]->addOne($attached, 'Data'); + } + + return $ids; + } + /** * @param array $params */ diff --git a/core/components/minishop3/tests/ProductCatalogPerfTest.php b/core/components/minishop3/tests/ProductCatalogPerfTest.php new file mode 100644 index 00000000..8a059543 --- /dev/null +++ b/core/components/minishop3/tests/ProductCatalogPerfTest.php @@ -0,0 +1,57 @@ +