diff --git a/src/app/apolloClient.js b/src/app/apolloClient.js index 29ae766..299f62f 100644 --- a/src/app/apolloClient.js +++ b/src/app/apolloClient.js @@ -1,30 +1,96 @@ import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client"; import { createPersistedQueryLink } from "apollo-link-persisted-queries"; +import gql from "graphql-tag"; const cachedZones = require("./cached-data/zones.json"); const cachedZonesById = new Map(cachedZones.map((z) => [z.id, z])); +// Teach Apollo to load certain fields from the cache, to avoid extra network +// requests. This happens a lot - e.g. reusing data from item search on the +// outfit immediately! const typePolicies = { Query: { fields: { - // Teach Apollo how to serve `items` queries from the cache. That way, - // when you remove an item from your outfit, or add an item from search, - // Apollo knows it already has the data it needs and doesn't need to ask - // the server again! items: (_, { args, toReference }) => { return args.ids.map((id) => toReference({ __typename: "Item", id }, true) ); }, - - // Similar for a single item lookup! item: (_, { args, toReference }) => { return toReference({ __typename: "Item", id: args.id }, true); }, - petAppearanceById: (_, { args, toReference }) => { return toReference({ __typename: "PetAppearance", id: args.id }, true); }, + species: (_, { args, toReference }) => { + return toReference({ __typename: "Species", id: args.id }, true); + }, + color: (_, { args, toReference }) => { + return toReference({ __typename: "Color", id: args.id }, true); + }, + }, + }, + + Item: { + fields: { + appearanceOn: (appearance, { args, readField, toReference }) => { + // If we already have this exact appearance in the cache, serve it! + if (appearance) { + return appearance; + } + + // Otherwise, we're going to see if this is a standard color, in which + // case we can reuse the standard color appearance if we already have + // it! This helps for fast loading when switching between standard + // colors. + + const { speciesId, colorId } = args; + + // HACK: I can't find a way to do bigger-picture queries like this from + // Apollo's cache field reader API. Am I missing something? I + // don't love escape-hatching to the client like this, but... + let cachedData; + try { + cachedData = client.readQuery({ + query: gql` + query CacheLookupForItemAppearanceReader( + $speciesId: ID! + $colorId: ID! + ) { + species(id: $speciesId) { + standardBodyId + } + color(id: $colorId) { + isStandard + } + } + `, + variables: { speciesId, colorId }, + }); + } catch (e) { + // Some errors are expected while setting up the cache... not sure + // how to distinguish from Real errors. Just gonna ignore them all + // for now! + return undefined; + } + + if (!cachedData) { + // This is an expected case while the page is loading. + return undefined; + } + + const { species, color } = cachedData; + if (color.isStandard) { + const itemId = readField("id"); + const bodyId = species.standardBodyId; + return toReference({ + __typename: "ItemAppearance", + id: `item-${itemId}-body-${bodyId}`, + }); + } else { + return undefined; + } + }, }, }, @@ -54,8 +120,10 @@ const httpLink = createHttpLink({ uri: "/api/graphql" }); * apolloClient is the global Apollo Client instance we use for GraphQL * queries. This is how we communicate with the server! */ -export default new ApolloClient({ +const client = new ApolloClient({ link: persistedQueryLink.concat(httpLink), cache: new InMemoryCache({ typePolicies }), connectToDevTools: true, }); + +export default client; diff --git a/src/app/components/SpeciesColorPicker.js b/src/app/components/SpeciesColorPicker.js index c50706c..dedc8a9 100644 --- a/src/app/components/SpeciesColorPicker.js +++ b/src/app/components/SpeciesColorPicker.js @@ -32,12 +32,13 @@ function SpeciesColorPicker({ allSpecies { id name + standardBodyId # Used for keeping items on during standard color changes } allColors { id name - isStandard # Not used here, but helpful for caching! + isStandard # Used for keeping items on during standard color changes } } `); diff --git a/src/server/index.js b/src/server/index.js index 7d75785..c0ce3c0 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -182,6 +182,11 @@ const typeDefs = gql` type Species @cacheControl(maxAge: 604800) { id: ID! name: String! + + # The bodyId for PetAppearances that use this species and a standard color. + # We use this to preload the standard body IDs, so that items stay when + # switching between standard colors. + standardBodyId: ID! } type SpeciesColorPair { @@ -231,6 +236,9 @@ const typeDefs = gql` petAppearances(speciesId: ID!, colorId: ID!): [PetAppearance!]! outfit(id: ID!): Outfit + color(id: ID!): Color + species(id: ID!): Species + petOnNeopetsDotCom(petName: String!): Outfit } @@ -523,6 +531,13 @@ const resolvers = { const speciesTranslation = await speciesTranslationLoader.load(id); return capitalize(speciesTranslation.name); }, + standardBodyId: async ({ id }, _, { petTypeBySpeciesAndColorLoader }) => { + const petType = await petTypeBySpeciesAndColorLoader.load({ + speciesId: id, + colorId: "8", // Blue + }); + return petType.bodyId; + }, }, Outfit: { name: async ({ id }, _, { outfitLoader }) => { @@ -551,8 +566,8 @@ const resolvers = { const allColors = await colorLoader.loadAll(); return allColors; }, - allSpecies: async (_, { ids }, { loadAllSpecies }) => { - const allSpecies = await loadAllSpecies(); + allSpecies: async (_, { ids }, { speciesLoader }) => { + const allSpecies = await speciesLoader.loadAll(); return allSpecies; }, allValidSpeciesColorPairs: async (_, __, { loadAllPetTypes }) => { @@ -645,6 +660,20 @@ const resolvers = { }; return outfit; }, + color: async (_, { id }, { colorLoader }) => { + const color = await colorLoader.load(id); + if (!color) { + return null; + } + return { id }; + }, + species: async (_, { id }, { speciesLoader }) => { + const species = await speciesLoader.load(id); + if (!species) { + return null; + } + return { id }; + }, }, Mutation: { setManualSpecialColor: async ( diff --git a/src/server/loaders.js b/src/server/loaders.js index 8323a2d..0b08f0c 100644 --- a/src/server/loaders.js +++ b/src/server/loaders.js @@ -52,10 +52,36 @@ const buildColorTranslationLoader = (db) => ); }); -const loadAllSpecies = (db) => async () => { - const [rows, _] = await db.execute(`SELECT * FROM species`); - const entities = rows.map(normalizeRow); - return entities; +const buildSpeciesLoader = (db) => { + const speciesLoader = new DataLoader(async (speciesIds) => { + const qs = speciesIds.map((_) => "?").join(","); + const [rows, _] = await db.execute( + `SELECT * FROM species WHERE id IN (${qs})`, + speciesIds + ); + + const entities = rows.map(normalizeRow); + const entitiesBySpeciesId = new Map(entities.map((e) => [e.id, e])); + + return speciesIds.map( + (speciesId) => + entitiesBySpeciesId.get(String(speciesId)) || + new Error(`could not find color ${speciesId}`) + ); + }); + + speciesLoader.loadAll = async () => { + const [rows, _] = await db.execute(`SELECT * FROM species`); + const entities = rows.map(normalizeRow); + + for (const species of entities) { + speciesLoader.prime(species.id, species); + } + + return entities; + }; + + return speciesLoader; }; const buildSpeciesTranslationLoader = (db) => @@ -409,7 +435,6 @@ const buildZoneTranslationLoader = (db) => function buildLoaders(db) { const loaders = {}; - loaders.loadAllSpecies = loadAllSpecies(db); loaders.loadAllPetTypes = loadAllPetTypes(db); loaders.colorLoader = buildColorLoader(db); @@ -435,6 +460,7 @@ function buildLoaders(db) { db, loaders ); + loaders.speciesLoader = buildSpeciesLoader(db); loaders.speciesTranslationLoader = buildSpeciesTranslationLoader(db); loaders.zoneLoader = buildZoneLoader(db); loaders.zoneTranslationLoader = buildZoneTranslationLoader(db); diff --git a/src/server/query-tests/Color.test.js b/src/server/query-tests/Color.test.js index 4d13cdf..3f64d19 100644 --- a/src/server/query-tests/Color.test.js +++ b/src/server/query-tests/Color.test.js @@ -2,6 +2,48 @@ const gql = require("graphql-tag"); const { query, getDbCalls } = require("./setup.js"); describe("Color", () => { + it("loads a single color", async () => { + const res = await query({ + query: gql` + query { + color(id: "8") { + id + name + isStandard + } + } + `, + }); + + expect(res).toHaveNoErrors(); + expect(res.data).toMatchInlineSnapshot(` + Object { + "color": Object { + "id": "8", + "isStandard": true, + "name": "Blue", + }, + } + `); + expect(getDbCalls()).toMatchInlineSnapshot(` + Array [ + Array [ + "SELECT * FROM colors WHERE id IN (?) AND prank = 0", + Array [ + "8", + ], + ], + Array [ + "SELECT * FROM color_translations + WHERE color_id IN (?) AND locale = \\"en\\"", + Array [ + "8", + ], + ], + ] + `); + }); + it("loads all colors", async () => { const res = await query({ query: gql` diff --git a/src/server/query-tests/Species.test.js b/src/server/query-tests/Species.test.js index b16fb74..80083b8 100644 --- a/src/server/query-tests/Species.test.js +++ b/src/server/query-tests/Species.test.js @@ -2,6 +2,55 @@ const gql = require("graphql-tag"); const { query, getDbCalls } = require("./setup.js"); describe("Species", () => { + it("loads a single species", async () => { + const res = await query({ + query: gql` + query { + species(id: "1") { + id + name + standardBodyId + } + } + `, + }); + + expect(res).toHaveNoErrors(); + expect(res.data).toMatchInlineSnapshot(` + Object { + "species": Object { + "id": "1", + "name": "Acara", + "standardBodyId": "93", + }, + } + `); + expect(getDbCalls()).toMatchInlineSnapshot(` + Array [ + Array [ + "SELECT * FROM species WHERE id IN (?)", + Array [ + "1", + ], + ], + Array [ + "SELECT * FROM species_translations + WHERE species_id IN (?) AND locale = \\"en\\"", + Array [ + "1", + ], + ], + Array [ + "SELECT * FROM pet_types WHERE (species_id = ? AND color_id = ?)", + Array [ + "1", + "8", + ], + ], + ] + `); + }); + it("loads all species", async () => { const res = await query({ query: gql` @@ -9,6 +58,7 @@ describe("Species", () => { allSpecies { id name + standardBodyId } } `, @@ -82,6 +132,121 @@ describe("Species", () => { "55", ], ], + Array [ + "SELECT * FROM pet_types WHERE (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?)", + Array [ + "1", + "8", + "2", + "8", + "3", + "8", + "4", + "8", + "5", + "8", + "6", + "8", + "7", + "8", + "8", + "8", + "9", + "8", + "10", + "8", + "11", + "8", + "12", + "8", + "13", + "8", + "14", + "8", + "15", + "8", + "16", + "8", + "17", + "8", + "18", + "8", + "19", + "8", + "20", + "8", + "21", + "8", + "22", + "8", + "23", + "8", + "24", + "8", + "25", + "8", + "26", + "8", + "27", + "8", + "28", + "8", + "29", + "8", + "30", + "8", + "31", + "8", + "32", + "8", + "33", + "8", + "34", + "8", + "35", + "8", + "36", + "8", + "37", + "8", + "38", + "8", + "39", + "8", + "40", + "8", + "41", + "8", + "42", + "8", + "43", + "8", + "44", + "8", + "45", + "8", + "46", + "8", + "47", + "8", + "48", + "8", + "49", + "8", + "50", + "8", + "51", + "8", + "52", + "8", + "53", + "8", + "54", + "8", + "55", + "8", + ], + ], ] `); }); diff --git a/src/server/query-tests/__snapshots__/Species.test.js.snap b/src/server/query-tests/__snapshots__/Species.test.js.snap index 9cf5f3c..dd21851 100644 --- a/src/server/query-tests/__snapshots__/Species.test.js.snap +++ b/src/server/query-tests/__snapshots__/Species.test.js.snap @@ -6,222 +6,277 @@ Object { Object { "id": "1", "name": "Acara", + "standardBodyId": "93", }, Object { "id": "2", "name": "Aisha", + "standardBodyId": "106", }, Object { "id": "3", "name": "Blumaroo", + "standardBodyId": "47", }, Object { "id": "4", "name": "Bori", + "standardBodyId": "84", }, Object { "id": "5", "name": "Bruce", + "standardBodyId": "146", }, Object { "id": "6", "name": "Buzz", + "standardBodyId": "250", }, Object { "id": "7", "name": "Chia", + "standardBodyId": "212", }, Object { "id": "8", "name": "Chomby", + "standardBodyId": "74", }, Object { "id": "9", "name": "Cybunny", + "standardBodyId": "94", }, Object { "id": "10", "name": "Draik", + "standardBodyId": "132", }, Object { "id": "11", "name": "Elephante", + "standardBodyId": "56", }, Object { "id": "12", "name": "Eyrie", + "standardBodyId": "90", }, Object { "id": "13", "name": "Flotsam", + "standardBodyId": "136", }, Object { "id": "14", "name": "Gelert", + "standardBodyId": "138", }, Object { "id": "15", "name": "Gnorbu", + "standardBodyId": "166", }, Object { "id": "16", "name": "Grarrl", + "standardBodyId": "119", }, Object { "id": "17", "name": "Grundo", + "standardBodyId": "126", }, Object { "id": "18", "name": "Hissi", + "standardBodyId": "67", }, Object { "id": "19", "name": "Ixi", + "standardBodyId": "163", }, Object { "id": "20", "name": "Jetsam", + "standardBodyId": "147", }, Object { "id": "21", "name": "Jubjub", + "standardBodyId": "80", }, Object { "id": "22", "name": "Kacheek", + "standardBodyId": "117", }, Object { "id": "23", "name": "Kau", + "standardBodyId": "201", }, Object { "id": "24", "name": "Kiko", + "standardBodyId": "51", }, Object { "id": "25", "name": "Koi", + "standardBodyId": "208", }, Object { "id": "26", "name": "Korbat", + "standardBodyId": "196", }, Object { "id": "27", "name": "Kougra", + "standardBodyId": "143", }, Object { "id": "28", "name": "Krawk", + "standardBodyId": "150", }, Object { "id": "29", "name": "Kyrii", + "standardBodyId": "175", }, Object { "id": "30", "name": "Lenny", + "standardBodyId": "173", }, Object { "id": "31", "name": "Lupe", + "standardBodyId": "199", }, Object { "id": "32", "name": "Lutari", + "standardBodyId": "52", }, Object { "id": "33", "name": "Meerca", + "standardBodyId": "109", }, Object { "id": "34", "name": "Moehog", + "standardBodyId": "134", }, Object { "id": "35", "name": "Mynci", + "standardBodyId": "95", }, Object { "id": "36", "name": "Nimmo", + "standardBodyId": "96", }, Object { "id": "37", "name": "Ogrin", + "standardBodyId": "154", }, Object { "id": "38", "name": "Peophin", + "standardBodyId": "55", }, Object { "id": "39", "name": "Poogle", + "standardBodyId": "76", }, Object { "id": "40", "name": "Pteri", + "standardBodyId": "156", }, Object { "id": "41", "name": "Quiggle", + "standardBodyId": "78", }, Object { "id": "42", "name": "Ruki", + "standardBodyId": "191", }, Object { "id": "43", "name": "Scorchio", + "standardBodyId": "187", }, Object { "id": "44", "name": "Shoyru", + "standardBodyId": "46", }, Object { "id": "45", "name": "Skeith", + "standardBodyId": "178", }, Object { "id": "46", "name": "Techo", + "standardBodyId": "100", }, Object { "id": "47", "name": "Tonu", + "standardBodyId": "130", }, Object { "id": "48", "name": "Tuskaninny", + "standardBodyId": "188", }, Object { "id": "49", "name": "Uni", + "standardBodyId": "257", }, Object { "id": "50", "name": "Usul", + "standardBodyId": "206", }, Object { "id": "51", "name": "Wocky", + "standardBodyId": "101", }, Object { "id": "52", "name": "Xweetok", + "standardBodyId": "68", }, Object { "id": "53", "name": "Yurble", + "standardBodyId": "182", }, Object { "id": "54", "name": "Zafara", + "standardBodyId": "180", }, Object { "id": "55", "name": "Vandagyre", + "standardBodyId": "306", }, ], }