Build itemSearchV2 in GQL

The main change is that we restructure the query, so that only the parts that are actually affected by pagination depend on those variables!

This will enable the Apollo Cache to trivially cache and show `numTotalItems` while waiting for other pages to load.
This commit is contained in:
Emi Matchu 2021-06-21 10:30:41 -07:00
parent 367a527a6f
commit c38678cf1a
2 changed files with 236 additions and 85 deletions

View file

@ -297,7 +297,110 @@ const itemSearchKindConditions = {
PB: `description LIKE "%This item is part of a deluxe paint brush set!%"`, 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) => { new DataLoader(async (queries) => {
// This isn't actually optimized as a batch query, we're just using a // This isn't actually optimized as a batch query, we're just using a
// DataLoader API consistency with our other loaders! // DataLoader API consistency with our other loaders!
@ -315,84 +418,37 @@ const buildItemSearchLoader = (db, loaders) =>
const actualOffset = offset || 0; const actualOffset = offset || 0;
const actualLimit = Math.min(limit || 30, 30); const actualLimit = Math.min(limit || 30, 30);
// Split the query into words, and search for each word as a substring const {
// of the name. queryJoins,
const words = query.split(/\s+/); queryConditions,
const wordMatchersForMysql = words.map( queryConditionValues,
(word) => "%" + word.replace(/_%/g, "\\$0") + "%" } = 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 entities = rows.map(normalizeRow);
const { numTotalItems } = totalRows[0];
for (const item of entities) { for (const item of entities) {
loaders.itemLoader.prime(item.id, item); loaders.itemLoader.prime(item.id, item);
} }
return [entities, numTotalItems]; return entities;
} }
); );
@ -1248,7 +1304,10 @@ function buildLoaders(db) {
loaders.itemLoader = buildItemLoader(db); loaders.itemLoader = buildItemLoader(db);
loaders.itemTranslationLoader = buildItemTranslationLoader(db); loaders.itemTranslationLoader = buildItemTranslationLoader(db);
loaders.itemByNameLoader = buildItemByNameLoader(db, loaders); 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.newestItemsLoader = buildNewestItemsLoader(db, loaders);
loaders.itemsThatNeedModelsLoader = buildItemsThatNeedModelsLoader(db); loaders.itemsThatNeedModelsLoader = buildItemsThatNeedModelsLoader(db);
loaders.itemBodiesWithAppearanceDataLoader = buildItemBodiesWithAppearanceDataLoader( loaders.itemBodiesWithAppearanceDataLoader = buildItemBodiesWithAppearanceDataLoader(

View file

@ -167,8 +167,7 @@ const typeDefs = gql`
} }
# TODO: I guess I didn't add the NC/NP/PB filter to this. Does that cause # 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 # bugs in comparing results on the client?
# this out for a better merge function?)
type ItemSearchResult { type ItemSearchResult {
query: String! query: String!
zones: [Zone!]! zones: [Zone!]!
@ -176,6 +175,15 @@ const typeDefs = gql`
numTotalItems: Int! 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 { type ItemTrade {
id: ID! id: ID!
user: User! user: User!
@ -195,6 +203,7 @@ const typeDefs = gql`
itemsByName(names: [String!]!): [Item]! itemsByName(names: [String!]!): [Item]!
# Search for items with fuzzy matching. # Search for items with fuzzy matching.
# Deprecated: Prefer itemSearchV2 instead! (A lot is not yet ported tho!)
itemSearch( itemSearch(
query: String! query: String!
fitsPet: FitsPetSearchFilter fitsPet: FitsPetSearchFilter
@ -205,6 +214,15 @@ const typeDefs = gql`
limit: Int limit: Int
): ItemSearchResult! ): 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 # Deprecated: an alias for itemSearch, but with speciesId and colorId
# required, serving the same purpose as fitsPet in itemSearch. # required, serving the same purpose as fitsPet in itemSearch.
itemSearchToFit( itemSearchToFit(
@ -642,7 +660,12 @@ const resolvers = {
offset, offset,
limit, limit,
}, },
{ itemSearchLoader, petTypeBySpeciesAndColorLoader, currentUserId } {
itemSearchNumTotalItemsLoader,
itemSearchItemsLoader,
petTypeBySpeciesAndColorLoader,
currentUserId,
}
) => { ) => {
let bodyId = null; let bodyId = null;
if (fitsPet) { if (fitsPet) {
@ -658,19 +681,50 @@ const resolvers = {
} }
bodyId = petType.bodyId; bodyId = petType.bodyId;
} }
const [items, numTotalItems] = await itemSearchLoader.load({ const [items, numTotalItems] = await Promise.all([
query: query.trim(), itemSearchItemsLoader.load({
bodyId, query: query.trim(),
itemKind, bodyId,
currentUserOwnsOrWants, itemKind,
currentUserId, currentUserOwnsOrWants,
zoneIds, currentUserId,
offset, zoneIds,
limit, offset,
}); limit,
}),
itemSearchNumTotalItemsLoader.load({
query: query.trim(),
bodyId,
itemKind,
currentUserOwnsOrWants,
currentUserId,
zoneIds,
}),
]);
const zones = zoneIds.map((id) => ({ id })); const zones = zoneIds.map((id) => ({ id }));
return { query, zones, items, numTotalItems }; 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 ( 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: { Mutation: {
addToItemsCurrentUserOwns: async ( addToItemsCurrentUserOwns: async (
_, _,