import assert, { AssertionError } from 'assert'; import { intersection, union } from 'lodash'; import { AbstractRepository, EntityManager, EntityNotFoundError, EntityRepository, FindManyOptions, getCustomRepository, getManager, } from 'typeorm'; import { RetailerProduct } from '@/entities'; import { RetailerProductSource } from '@/entities/types'; import logger from '@/logger'; import { AggregateProductRepository } from '..'; import { assertAreValidIdentifiers } from '../shared/identifiers'; import { isValidRetailerProduct, RetailerProductDataWithIdentifiers, } from './types'; import { mergeDuplicatesByIdentifiers } from './utils'; /** * Repository for the RetailerProduct entity. Primarily responsible for * handling identity resolution. */ @EntityRepository(RetailerProduct) // eslint-disable-next-line max-len export default class RetailerProductRepository extends AbstractRepository { async bulkFindOneByIdentifiers( input: { identifiers: string[]; retailer: string }[], manager: EntityManager = getManager() ): Promise<(RetailerProduct | null)[]> { if (input.some(({ identifiers }) => identifiers.length === 0)) { throw new AssertionError({ message: 'No identifiers specified when calling RetailerProductRepository.bulkFindOneByIdentifiers', expected: true, actual: false, }); } const allIdentifiers = input.reduce((prev, { identifiers }) => { return [...prev, ...identifiers]; }, new Array()); const allRetailers = input.reduce((prev, { retailer }) => { return [...prev, retailer]; }, new Array()); const products = await manager .createQueryBuilder(RetailerProduct, 'RetailerProduct') // finds any where a single identifer matches .where('RetailerProduct.identifiers && :identifiers', { identifiers: allIdentifiers, }) // and where retailer is in the retailer list .andWhere('RetailerProduct.retailer IN (:...retailers)', { retailers: allRetailers, }) .getMany(); const retailerToProductsMap = new Map(); products.forEach((product) => { retailerToProductsMap.set(product.retailer, [ ...(retailerToProductsMap.get(product.retailer) || []), product, ]); }); return input.map(({ identifiers, retailer }) => { const eligibleProducts = retailerToProductsMap .get(retailer) ?.filter((product) => { return product.identifiers.some((identifier) => identifiers.includes(identifier) ); }); if (!eligibleProducts || eligibleProducts.length === 0) { return null; } // in event that multiple are present, we could consider warning, but // this is the same behavior as findOneByIdentifiers const matchingProduct = eligibleProducts[0]; const matchingIdentifiers = intersection( matchingProduct.identifiers, identifiers ); logger.info( `Found matching identifiers for RetailerProduct: ${matchingIdentifiers}` ); return matchingProduct; }); } async findOneByIdentifiers( { identifiers, retailer }: { identifiers: string[]; retailer: string }, manager: EntityManager = getManager() ): Promise { if (identifiers.length === 0) { logger.warn( 'No identifiers specified when calling RetailerProductRepository.findByIdentifiers' ); return undefined; } return ( manager .createQueryBuilder(RetailerProduct, 'RetailerProduct') // finds any where a single identifier matches .where('RetailerProduct.identifiers && :identifiers', { identifiers, }) .andWhere('RetailerProduct.retailer = :retailer', { retailer }) .getOne() ); } async findOneByIdentifiersOrFail( { identifiers, retailer }: { identifiers: string[]; retailer: string }, manager: EntityManager = getManager() ): Promise { const product = await this.findOneByIdentifiers( { identifiers, retailer }, manager ); if (!product) { throw new EntityNotFoundError(RetailerProduct, { identifiers }); } return product; } /** * Upsert a RetailerProduct based on passed in `identifiers`. */ async upsert( { data, productIdentifiers, retailer, source, }: RetailerProductDataWithIdentifiers & { source?: RetailerProductSource }, manager: EntityManager = getManager() ): Promise<{ retailerProductId: string; aggregateProductId: string; }> { assert(isValidRetailerProduct(data), 'Missing retailer product data'); assertAreValidIdentifiers(productIdentifiers); const { aggregateProductId } = await getCustomRepository( AggregateProductRepository ).upsert({ data, identifiers: productIdentifiers }, manager); let existingProduct = await this.findOneByIdentifiers( { identifiers: productIdentifiers, retailer }, manager ); if (existingProduct) { existingProduct = Object.assign(existingProduct, { ...data, aggregateProductId, identifiers: union(existingProduct.identifiers, productIdentifiers), imageUrls: union(existingProduct.imageUrls, data.imageUrls), source, }); const retailerProduct = await manager.save(existingProduct); return { retailerProductId: retailerProduct.id, aggregateProductId: retailerProduct.aggregateProductId, }; } const savedRetailerProduct = await manager.save( RetailerProduct, manager.create(RetailerProduct, { ...data, identifiers: productIdentifiers, aggregateProductId, retailer, source, }) ); return { aggregateProductId, retailerProductId: savedRetailerProduct.id, }; } /** * Bulk upsert RetailerProducts */ async bulkUpsert( { data, source, }: { data: RetailerProductDataWithIdentifiers[]; source?: RetailerProductSource; }, manager: EntityManager = getManager() ): Promise<{ retailerProducts: { identifiers: string[]; id: string }[]; aggregateProductIds: string[]; }> { const retailerProductEntities: RetailerProduct[] = []; const dedupedData = mergeDuplicatesByIdentifiers(data); const { aggregateProducts } = await getCustomRepository( AggregateProductRepository ).bulkUpsert( dedupedData.map(({ data, productIdentifiers }) => ({ data, identifiers: productIdentifiers, })), manager ); const products = await this.bulkFindOneByIdentifiers( dedupedData.map(({ productIdentifiers, retailer }) => ({ identifiers: productIdentifiers, retailer, })), manager ); dedupedData.forEach(({ data, productIdentifiers, retailer }, index) => { const product = products[index]; const aggregateProductId = aggregateProducts.find( ({ identifiers }) => intersection(identifiers, productIdentifiers).length > 0 )?.id; if (product) { retailerProductEntities.push( Object.assign(product, { ...data, source, aggregateProductId, identifiers: union(product.identifiers, productIdentifiers), imageUrls: union(product.imageUrls, data.imageUrls), }) ); } else { retailerProductEntities.push( manager.create(RetailerProduct, { ...data, source, aggregateProductId, identifiers: productIdentifiers, retailer, }) ); } }); const savedRetailerProducts = await manager.save( RetailerProduct, retailerProductEntities ); return { aggregateProductIds: aggregateProducts.map(({ id }) => id), retailerProducts: savedRetailerProducts.map(({ id, identifiers }) => ({ id, identifiers, })), }; } /** * Find all products by retailer. */ async findByRetailer( { retailer, }: { retailer: string; }, options?: FindManyOptions, manager: EntityManager = getManager() ): Promise { return manager.find(RetailerProduct, { ...options, where: { retailer, }, }); } }