Skip to content

Commit b227de4

Browse files
committed
perf(catalog): fix large-catalog bottlenecks
1 parent ffb78ec commit b227de4

12 files changed

Lines changed: 526 additions & 283 deletions

File tree

packages/evershop/src/components/frontStore/catalog/DefaultAttributeFilterRender.tsx

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Button } from '@components/common/ui/Button.js';
22
import { Checkbox } from '@components/common/ui/Checkbox.js';
3+
import { Input } from '@components/common/ui/Input.js';
34
import { Label } from '@components/common/ui/Label.js';
45
import {
56
FilterableAttribute,
@@ -15,6 +16,9 @@ export const DefaultAttributeFilterRender: React.FC<{
1516
}> = ({ availableAttributes, currentFilters }) => {
1617
const { updateFilter } = useProductFilter();
1718
const [searchTerms, setSearchTerms] = useState<{ [key: string]: string }>({});
19+
const [expandedAttributes, setExpandedAttributes] = useState<{
20+
[key: string]: boolean;
21+
}>({});
1822

1923
const handleAttributeChange = (
2024
attributeCode: string,
@@ -93,6 +97,7 @@ export const DefaultAttributeFilterRender: React.FC<{
9397
{availableAttributes.map((attribute) => {
9498
const selectedCount = getSelectedCount(attribute.attributeCode);
9599
const filteredOptions = getFilteredOptions(attribute);
100+
const isExpanded = !!expandedAttributes[attribute.attributeCode];
96101

97102
return (
98103
<div
@@ -119,21 +124,25 @@ export const DefaultAttributeFilterRender: React.FC<{
119124
<div className="filter__content">
120125
{attribute.options.length > 5 && (
121126
<div className="mb-3">
122-
<Checkbox
127+
<Input
128+
type="search"
129+
placeholder={_('Search options')}
123130
value={searchTerms[attribute.attributeCode] || ''}
124-
onCheckedChange={(checked) =>
131+
onChange={(e) =>
125132
setSearchTerms((prev) => ({
126133
...prev,
127-
[attribute.attributeCode]: checked
128-
? checked.toString()
129-
: ''
134+
[attribute.attributeCode]: e.target.value
130135
}))
131136
}
132137
/>
133138
</div>
134139
)}
135140

136-
<div className="attribute__options space-y-2.5 max-h-48 overflow-y-auto">
141+
<div
142+
className={`attribute__options space-y-2.5 ${
143+
isExpanded ? '' : 'max-h-48 overflow-y-auto'
144+
}`}
145+
>
137146
{filteredOptions.length > 0 ? (
138147
filteredOptions.map((option) => {
139148
const isSelected = isOptionSelected(
@@ -177,12 +186,22 @@ export const DefaultAttributeFilterRender: React.FC<{
177186
{!searchTerms[attribute.attributeCode] &&
178187
attribute.options.length > 10 && (
179188
<Button
189+
type="button"
180190
variant={'link'}
181191
className="text-primary text-sm mt-2 hover:underline"
192+
onClick={() =>
193+
setExpandedAttributes((prev) => ({
194+
...prev,
195+
[attribute.attributeCode]:
196+
!prev[attribute.attributeCode]
197+
}))
198+
}
182199
>
183-
{_('Show all ${count} options', {
184-
count: attribute.options.length.toString()
185-
})}
200+
{isExpanded
201+
? _('Show less')
202+
: _('Show all ${count} options', {
203+
count: attribute.options.length.toString()
204+
})}
186205
</Button>
187206
)}
188207
</div>

packages/evershop/src/modules/catalog/graphql/types/FeaturedProduct/FeaturedProduct.resolvers.js

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,22 @@ export default {
3434
query.where('product.status', '=', 1);
3535
query.andWhere('product.visibility', '=', 1);
3636
if (getShowOutOfStockProducts() === false) {
37-
query
38-
.andWhere('product_inventory.manage_stock', '=', false)
39-
.addNode(
40-
node('OR')
41-
.addLeaf('AND', 'product_inventory.qty', '>', 0)
42-
.addLeaf('AND', 'product_inventory.stock_availability', '=', true)
43-
);
37+
// Wrap the disjunction in its own node — see Variant.resolvers.js:
38+
// the chained form let disabled/invisible products with stock leak
39+
// into the featured list via the unguarded OR operand.
40+
const stockFilter = node('AND');
41+
stockFilter.addLeaf(
42+
'AND',
43+
'product_inventory.manage_stock',
44+
'=',
45+
false
46+
);
47+
stockFilter.addNode(
48+
node('OR')
49+
.addLeaf('AND', 'product_inventory.qty', '>', 0)
50+
.addLeaf('AND', 'product_inventory.stock_availability', '=', true)
51+
);
52+
query.getWhere().addNode(stockFilter);
4453
}
4554
query.groupBy(
4655
'product.product_id',

packages/evershop/src/modules/catalog/graphql/types/Product/Variant/Variant.resolvers.js

Lines changed: 39 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,25 @@ export default {
5656
);
5757

5858
if (!user && getShowOutOfStockProducts() === false) {
59-
query
60-
.andWhere('product_inventory.manage_stock', '=', false)
61-
.addNode(
62-
node('OR')
63-
.addLeaf('AND', 'product_inventory.qty', '>', 0)
64-
.addLeaf(
65-
'AND',
66-
'product_inventory.stock_availability',
67-
'=',
68-
true
69-
)
70-
);
59+
// Wrap the disjunction in its own node. Chaining
60+
// .andWhere(...).addNode(node('OR')...) leaves the OR at the same
61+
// tree level as every LATER andWhere — SQL precedence then turns
62+
// "A OR (B AND C) AND <rest>" into "A OR (...)", so any
63+
// manage_stock=false product in the store bypassed the variant
64+
// group / attribute / status filters below.
65+
const stockFilter = node('AND');
66+
stockFilter.addLeaf(
67+
'AND',
68+
'product_inventory.manage_stock',
69+
'=',
70+
false
71+
);
72+
stockFilter.addNode(
73+
node('OR')
74+
.addLeaf('AND', 'product_inventory.qty', '>', 0)
75+
.addLeaf('AND', 'product_inventory.stock_availability', '=', true)
76+
);
77+
query.getWhere().addNode(stockFilter);
7178
}
7279

7380
query.andWhere('variant_group_id', '=', variantGroupId);
@@ -108,33 +115,24 @@ export default {
108115
options
109116
};
110117
}),
111-
items: () =>
112-
vs
113-
.reduce((acc, v) => {
114-
const product = acc.find((p) => p.product_id === v.product_id);
115-
if (!product) {
116-
acc.push({
117-
product_id: v.product_id,
118-
attributes: [
119-
{
120-
attributeId: v.attribute_id,
121-
attributeCode: v.attribute_code,
122-
optionId: v.option_id,
123-
optionText: v.option_text
124-
}
125-
]
126-
});
127-
} else {
128-
product.attributes.push({
129-
attributeId: v.attribute_id,
130-
attributeCode: v.attribute_code,
131-
optionId: v.option_id,
132-
optionText: v.option_text
133-
});
134-
}
135-
return acc;
136-
}, [])
137-
.map((p) => {
118+
items: () => {
119+
// Group rows per product with a Map — the previous
120+
// find()-in-reduce was O(n²) over the variant list.
121+
const byProduct = new Map();
122+
for (const v of vs) {
123+
let entry = byProduct.get(v.product_id);
124+
if (!entry) {
125+
entry = { product_id: v.product_id, attributes: [] };
126+
byProduct.set(v.product_id, entry);
127+
}
128+
entry.attributes.push({
129+
attributeId: v.attribute_id,
130+
attributeCode: v.attribute_code,
131+
optionId: v.option_id,
132+
optionText: v.option_text
133+
});
134+
}
135+
return [...byProduct.values()].map((p) => {
138136
const productAttributes = p.attributes.map(
139137
(a) => a.attributeCode
140138
);
@@ -153,7 +151,8 @@ export default {
153151
(a) => a.attributeCode
154152
)
155153
};
156-
}),
154+
});
155+
},
157156
addItemApi: buildUrl('addVariantItem', { id: group.uuid })
158157
};
159158
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { execute } from '@evershop/postgres-query-builder';
2+
import type { PoolClient } from 'pg';
3+
4+
/**
5+
* Large-catalog indexing fixes (found load-testing a 500k-product catalog).
6+
*
7+
* 1. Index `url_rewrite.request_path`. Every storefront request resolves its
8+
* URL with `WHERE request_path = $1`; the only indexes were the pkey and
9+
* the `entity_uuid` unique, so each page view paid a full seq scan
10+
* (~200ms at 500k rewrite rows) and thrashed the buffer cache. Not UNIQUE
11+
* on purpose: slugs may collide across entity types — the lookup breaks
12+
* ties with a deterministic ORDER BY instead.
13+
*
14+
* 2. Drop duplicate indexes that only amplified write cost (each is fully
15+
* covered by a UNIQUE constraint on the same leading column):
16+
* - FK_PRODUCT_DESCRIPTION ⊂ PRODUCT_ID_UNIQUE
17+
* - FK_CATEGORY_DESCRIPTION ⊂ CATEGORY_ID_UNIQUE
18+
* - FK_ATTRIBUTE_LINK ⊂ ATTRIBUTE_GROUP_LINK_UNIQUE
19+
* - FK_PRODUCT_ATTRIBUTE_LINK ⊂ OPTION_VALUE_UNIQUE
20+
* Referencing-side FK lookups (cascade deletes, joins) keep using the
21+
* unique indexes — Postgres only requires an index on the referenced side.
22+
*/
23+
export default async (connection: PoolClient) => {
24+
await execute(
25+
connection,
26+
`CREATE INDEX IF NOT EXISTS "URL_REWRITE_REQUEST_PATH_INDEX"
27+
ON "url_rewrite" ("request_path")`
28+
);
29+
30+
await execute(connection, `DROP INDEX IF EXISTS "FK_PRODUCT_DESCRIPTION"`);
31+
await execute(connection, `DROP INDEX IF EXISTS "FK_CATEGORY_DESCRIPTION"`);
32+
await execute(connection, `DROP INDEX IF EXISTS "FK_ATTRIBUTE_LINK"`);
33+
await execute(
34+
connection,
35+
`DROP INDEX IF EXISTS "FK_PRODUCT_ATTRIBUTE_LINK"`
36+
);
37+
};

0 commit comments

Comments
 (0)