96 lines
2.8 KiB
Python
96 lines
2.8 KiB
Python
"""
|
|
@service CatalogSearchService
|
|
@purpose Token + phrase matching for catalog search without loading the full product set.
|
|
"""
|
|
import re
|
|
from typing import List, Optional
|
|
|
|
from sqlalchemy import and_, or_, select
|
|
from sqlalchemy.sql import Select
|
|
|
|
from app.models.BrandModel import Brand
|
|
from app.models.ProductModel import Product, ProductVariant
|
|
|
|
|
|
_TOKEN_RE = re.compile(r"[^\w+-]+", re.UNICODE)
|
|
|
|
|
|
def tokenize_search(query: Optional[str]) -> List[str]:
|
|
if not query:
|
|
return []
|
|
tokens = [t for t in _TOKEN_RE.split(query.strip()) if len(t) >= 2]
|
|
return tokens[:8]
|
|
|
|
|
|
def product_search_id_query(search: str) -> Select:
|
|
"""
|
|
Returns a SELECT of matching product_id values.
|
|
Matches the full phrase OR every token across name, description, SKU, and brand.
|
|
"""
|
|
phrase = f"%{search.strip()}%"
|
|
tokens = tokenize_search(search)
|
|
|
|
phrase_match = or_(
|
|
Product.name.ilike(phrase),
|
|
Product.description.ilike(phrase),
|
|
ProductVariant.sku.ilike(phrase),
|
|
ProductVariant.barcode.ilike(phrase),
|
|
Brand.name.ilike(phrase),
|
|
)
|
|
|
|
token_match = None
|
|
if tokens:
|
|
token_clauses = []
|
|
for token in tokens:
|
|
term = f"%{token}%"
|
|
token_clauses.append(
|
|
or_(
|
|
Product.name.ilike(term),
|
|
Product.description.ilike(term),
|
|
ProductVariant.sku.ilike(term),
|
|
ProductVariant.barcode.ilike(term),
|
|
Brand.name.ilike(term),
|
|
)
|
|
)
|
|
token_match = and_(*token_clauses)
|
|
|
|
where_clause = phrase_match if token_match is None else or_(phrase_match, token_match)
|
|
|
|
return (
|
|
select(Product.product_id)
|
|
.outerjoin(ProductVariant, ProductVariant.product_id == Product.product_id)
|
|
.outerjoin(Brand, Product.brand_id == Brand.brand_id)
|
|
.where(where_clause)
|
|
.distinct()
|
|
)
|
|
|
|
|
|
def apply_product_search(stmt: Select, search: Optional[str]) -> Select:
|
|
if not search or not search.strip():
|
|
return stmt
|
|
return stmt.where(Product.product_id.in_(product_search_id_query(search.strip())))
|
|
|
|
|
|
def sku_search_clause(search: Optional[str]):
|
|
if not search or not search.strip():
|
|
return None
|
|
phrase = f"%{search.strip()}%"
|
|
tokens = tokenize_search(search)
|
|
phrase_match = or_(
|
|
Product.name.ilike(phrase),
|
|
ProductVariant.sku.ilike(phrase),
|
|
ProductVariant.barcode.ilike(phrase),
|
|
)
|
|
if not tokens:
|
|
return phrase_match
|
|
token_match = and_(
|
|
*[
|
|
or_(
|
|
Product.name.ilike(f"%{token}%"),
|
|
ProductVariant.sku.ilike(f"%{token}%"),
|
|
ProductVariant.barcode.ilike(f"%{token}%"),
|
|
)
|
|
for token in tokens
|
|
]
|
|
)
|
|
return or_(phrase_match, token_match)
|