diff --git a/src/server/loaders.js b/src/server/loaders.js index 6d16469..18b59e8 100644 --- a/src/server/loaders.js +++ b/src/server/loaders.js @@ -297,7 +297,110 @@ const itemSearchKindConditions = { PB: `description LIKE "%This item is part of a deluxe paint brush set!%"`, }; -const buildItemSearchLoader = (db, loaders) => +function buildItemSearchConditions({ + query, + bodyId, + itemKind, + currentUserOwnsOrWants, + currentUserId, + zoneIds, +}) { + // Split the query into words, and search for each word as a substring + // of the name. + const words = query.split(/\s+/); + const wordMatchersForMysql = words.map( + (word) => "%" + word.replace(/_%/g, "\\$0") + "%" + ); + const matcherPlaceholders = words.map((_) => "t.name LIKE ?").join(" AND "); + + const itemKindCondition = itemSearchKindConditions[itemKind] || "1"; + const bodyIdCondition = bodyId + ? "(swf_assets.body_id = ? OR swf_assets.body_id = 0)" + : "1"; + const bodyIdValues = bodyId ? [bodyId] : []; + const zoneIdsCondition = + zoneIds.length > 0 + ? `swf_assets.zone_id IN (${zoneIds.map((_) => "?").join(", ")})` + : "1"; + const currentUserJoin = currentUserOwnsOrWants + ? `INNER JOIN closet_hangers ch ON ch.item_id = items.id` + : ""; + const currentUserCondition = currentUserOwnsOrWants + ? `ch.user_id = ? AND ch.owned = ?` + : "1"; + const currentUserValues = currentUserOwnsOrWants + ? [currentUserId, currentUserOwnsOrWants === "OWNS" ? "1" : "0"] + : []; + + const queryJoins = ` + INNER JOIN item_translations t ON t.item_id = items.id + INNER JOIN parents_swf_assets rel + ON rel.parent_type = "Item" AND rel.parent_id = items.id + INNER JOIN swf_assets ON rel.swf_asset_id = swf_assets.id + ${currentUserJoin} + `; + + const queryConditions = ` + (${matcherPlaceholders}) AND t.locale = "en" AND + (${bodyIdCondition}) AND (${zoneIdsCondition}) AND + (${itemKindCondition}) AND (${currentUserCondition}) + `; + const queryConditionValues = [ + ...wordMatchersForMysql, + ...bodyIdValues, + ...zoneIds, + ...currentUserValues, + ]; + + return { queryJoins, queryConditions, queryConditionValues }; +} + +const buildItemSearchNumTotalItemsLoader = (db) => + new DataLoader(async (queries) => { + // This isn't actually optimized as a batch query, we're just using a + // DataLoader API consistency with our other loaders! + const queryPromises = queries.map( + async ({ + query, + bodyId, + itemKind, + currentUserOwnsOrWants, + currentUserId, + zoneIds = [], + }) => { + const { + queryJoins, + queryConditions, + queryConditionValues, + } = buildItemSearchConditions({ + query, + bodyId, + itemKind, + currentUserOwnsOrWants, + currentUserId, + zoneIds, + }); + + const [totalRows] = await db.execute( + ` + SELECT count(DISTINCT items.id) AS numTotalItems FROM items + ${queryJoins} + WHERE ${queryConditions} + `, + queryConditionValues + ); + + const { numTotalItems } = totalRows[0]; + return numTotalItems; + } + ); + + const responses = await Promise.all(queryPromises); + + return responses; + }); + +const buildItemSearchItemsLoader = (db, loaders) => new DataLoader(async (queries) => { // This isn't actually optimized as a batch query, we're just using a // DataLoader API consistency with our other loaders! @@ -315,84 +418,37 @@ const buildItemSearchLoader = (db, loaders) => const actualOffset = offset || 0; const actualLimit = Math.min(limit || 30, 30); - // Split the query into words, and search for each word as a substring - // of the name. - const words = query.split(/\s+/); - const wordMatchersForMysql = words.map( - (word) => "%" + word.replace(/_%/g, "\\$0") + "%" + const { + queryJoins, + queryConditions, + queryConditionValues, + } = buildItemSearchConditions({ + query, + bodyId, + itemKind, + currentUserOwnsOrWants, + currentUserId, + zoneIds, + }); + + const [rows] = await db.execute( + ` + SELECT DISTINCT items.*, t.name FROM items + ${queryJoins} + WHERE ${queryConditions} + ORDER BY t.name + LIMIT ? OFFSET ? + `, + [...queryConditionValues, actualLimit, actualOffset] ); - const matcherPlaceholders = words - .map((_) => "t.name LIKE ?") - .join(" AND "); - - const itemKindCondition = itemSearchKindConditions[itemKind] || "1"; - const bodyIdCondition = bodyId - ? "(swf_assets.body_id = ? OR swf_assets.body_id = 0)" - : "1"; - const bodyIdValues = bodyId ? [bodyId] : []; - const zoneIdsCondition = - zoneIds.length > 0 - ? `swf_assets.zone_id IN (${zoneIds.map((_) => "?").join(", ")})` - : "1"; - const currentUserJoin = currentUserOwnsOrWants - ? `INNER JOIN closet_hangers ch ON ch.item_id = items.id` - : ""; - const currentUserCondition = currentUserOwnsOrWants - ? `ch.user_id = ? AND ch.owned = ?` - : "1"; - const currentUserValues = currentUserOwnsOrWants - ? [currentUserId, currentUserOwnsOrWants === "OWNS" ? "1" : "0"] - : []; - - const queryJoins = ` - INNER JOIN item_translations t ON t.item_id = items.id - INNER JOIN parents_swf_assets rel - ON rel.parent_type = "Item" AND rel.parent_id = items.id - INNER JOIN swf_assets ON rel.swf_asset_id = swf_assets.id - ${currentUserJoin} - `; - - const queryConditions = ` - (${matcherPlaceholders}) AND t.locale = "en" AND - (${bodyIdCondition}) AND (${zoneIdsCondition}) AND - (${itemKindCondition}) AND (${currentUserCondition}) - `; - const queryConditionValues = [ - ...wordMatchersForMysql, - ...bodyIdValues, - ...zoneIds, - ...currentUserValues, - ]; - - const [[rows], [totalRows]] = await Promise.all([ - db.execute( - ` - SELECT DISTINCT items.*, t.name FROM items - ${queryJoins} - WHERE ${queryConditions} - ORDER BY t.name - LIMIT ? OFFSET ? - `, - [...queryConditionValues, actualLimit, actualOffset] - ), - db.execute( - ` - SELECT count(DISTINCT items.id) AS numTotalItems FROM items - ${queryJoins} - WHERE ${queryConditions} - `, - queryConditionValues - ), - ]); const entities = rows.map(normalizeRow); - const { numTotalItems } = totalRows[0]; for (const item of entities) { loaders.itemLoader.prime(item.id, item); } - return [entities, numTotalItems]; + return entities; } ); @@ -1248,7 +1304,10 @@ function buildLoaders(db) { loaders.itemLoader = buildItemLoader(db); loaders.itemTranslationLoader = buildItemTranslationLoader(db); loaders.itemByNameLoader = buildItemByNameLoader(db, loaders); - loaders.itemSearchLoader = buildItemSearchLoader(db, loaders); + loaders.itemSearchNumTotalItemsLoader = buildItemSearchNumTotalItemsLoader( + db + ); + loaders.itemSearchItemsLoader = buildItemSearchItemsLoader(db, loaders); loaders.newestItemsLoader = buildNewestItemsLoader(db, loaders); loaders.itemsThatNeedModelsLoader = buildItemsThatNeedModelsLoader(db); loaders.itemBodiesWithAppearanceDataLoader = buildItemBodiesWithAppearanceDataLoader( diff --git a/src/server/types/Item.js b/src/server/types/Item.js index 336cb37..25d35af 100644 --- a/src/server/types/Item.js +++ b/src/server/types/Item.js @@ -167,8 +167,7 @@ const typeDefs = gql` } # TODO: I guess I didn't add the NC/NP/PB filter to this. Does that cause - # bugs in comparing results on the client? (Also, should we just throw - # this out for a better merge function?) + # bugs in comparing results on the client? type ItemSearchResult { query: String! zones: [Zone!]! @@ -176,6 +175,15 @@ const typeDefs = gql` numTotalItems: Int! } + # TODO: I guess I didn't add the NC/NP/PB filter to this. Does that cause + # bugs in comparing results on the client? + type ItemSearchResultV2 { + query: String! + zones: [Zone!]! + items(offset: Int, limit: Int): [Item!]! + numTotalItems: Int! + } + type ItemTrade { id: ID! user: User! @@ -195,6 +203,7 @@ const typeDefs = gql` itemsByName(names: [String!]!): [Item]! # Search for items with fuzzy matching. + # Deprecated: Prefer itemSearchV2 instead! (A lot is not yet ported tho!) itemSearch( query: String! fitsPet: FitsPetSearchFilter @@ -205,6 +214,15 @@ const typeDefs = gql` limit: Int ): ItemSearchResult! + # Search for items with fuzzy matching. + itemSearchV2( + query: String! + fitsPet: FitsPetSearchFilter + itemKind: ItemKindSearchFilter + currentUserOwnsOrWants: OwnsOrWants + zoneIds: [ID!] + ): ItemSearchResultV2! + # Deprecated: an alias for itemSearch, but with speciesId and colorId # required, serving the same purpose as fitsPet in itemSearch. itemSearchToFit( @@ -642,7 +660,12 @@ const resolvers = { offset, limit, }, - { itemSearchLoader, petTypeBySpeciesAndColorLoader, currentUserId } + { + itemSearchNumTotalItemsLoader, + itemSearchItemsLoader, + petTypeBySpeciesAndColorLoader, + currentUserId, + } ) => { let bodyId = null; if (fitsPet) { @@ -658,19 +681,50 @@ const resolvers = { } bodyId = petType.bodyId; } - const [items, numTotalItems] = await itemSearchLoader.load({ - query: query.trim(), - bodyId, - itemKind, - currentUserOwnsOrWants, - currentUserId, - zoneIds, - offset, - limit, - }); + const [items, numTotalItems] = await Promise.all([ + itemSearchItemsLoader.load({ + query: query.trim(), + bodyId, + itemKind, + currentUserOwnsOrWants, + currentUserId, + zoneIds, + offset, + limit, + }), + itemSearchNumTotalItemsLoader.load({ + query: query.trim(), + bodyId, + itemKind, + currentUserOwnsOrWants, + currentUserId, + zoneIds, + }), + ]); const zones = zoneIds.map((id) => ({ id })); return { query, zones, items, numTotalItems }; }, + itemSearchV2: async ( + _, + { query, fitsPet, itemKind, currentUserOwnsOrWants, zoneIds = [] }, + { petTypeBySpeciesAndColorLoader } + ) => { + let bodyId = null; + if (fitsPet) { + const petType = await petTypeBySpeciesAndColorLoader.load({ + speciesId: fitsPet.speciesId, + colorId: fitsPet.colorId, + }); + if (!petType) { + throw new Error( + `pet type not found: speciesId=${fitsPet.speciesId}, ` + + `colorId: ${fitsPet.colorId}` + ); + } + bodyId = petType.bodyId; + } + return { query, bodyId, itemKind, currentUserOwnsOrWants, zoneIds }; + }, itemSearchToFit: async ( _, { @@ -726,6 +780,44 @@ const resolvers = { }, }, + ItemSearchResultV2: { + numTotalItems: async ( + { query, bodyId, itemKind, currentUserOwnsOrWants, zoneIds }, + { offset, limit }, + { currentUserId, itemSearchNumTotalItemsLoader } + ) => { + const numTotalItems = await itemSearchNumTotalItemsLoader.load({ + query: query.trim(), + bodyId, + itemKind, + currentUserOwnsOrWants, + currentUserId, + zoneIds, + offset, + limit, + }); + return numTotalItems; + }, + items: async ( + { query, bodyId, itemKind, currentUserOwnsOrWants, zoneIds }, + { offset, limit }, + { currentUserId, itemSearchItemsLoader } + ) => { + const items = await itemSearchItemsLoader.load({ + query: query.trim(), + bodyId, + itemKind, + currentUserOwnsOrWants, + currentUserId, + zoneIds, + offset, + limit, + }); + return items.map(({ id }) => ({ id })); + }, + zones: ({ zoneIds }) => zoneIds.map((id) => ({ id })), + }, + Mutation: { addToItemsCurrentUserOwns: async ( _,