From 4a61919649c29cf7218b8f6b9d0827d7fb6f5486 Mon Sep 17 00:00:00 2001 From: Matt Dunn-Rankin Date: Thu, 23 Apr 2020 01:08:00 -0700 Subject: [PATCH] add GQL support for appearance data! --- package.json | 3 +- setup-mysql-user.sql | 5 + src/server/index.js | 82 ++++++++++++- src/server/index.test.js | 248 ++++++++++++++++++++++++++++++--------- src/server/loaders.js | 109 +++++++++++++++-- 5 files changed, 379 insertions(+), 68 deletions(-) diff --git a/package.json b/package.json index 8ac4050..51067ea 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", - "eject": "react-scripts eject" + "eject": "react-scripts eject", + "setup-mysql-user": "mysql -h impress.openneo.net -u matchu -p < setup-mysql-user.sql" }, "eslintConfig": { "extends": "react-app" diff --git a/setup-mysql-user.sql b/setup-mysql-user.sql index 86fdf84..6e82ff4 100644 --- a/setup-mysql-user.sql +++ b/setup-mysql-user.sql @@ -1,2 +1,7 @@ GRANT SELECT ON openneo_impress.items TO impress2020; GRANT SELECT ON openneo_impress.item_translations TO impress2020; +GRANT SELECT ON openneo_impress.parents_swf_assets TO impress2020; +GRANT SELECT ON openneo_impress.pet_types TO impress2020; +GRANT SELECT ON openneo_impress.swf_assets TO impress2020; +GRANT SELECT ON openneo_impress.zones TO impress2020; +GRANT SELECT ON openneo_impress.zone_translations TO impress2020; diff --git a/src/server/index.js b/src/server/index.js index 8f95acd..d1a7106 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -1,13 +1,36 @@ const { gql } = require("apollo-server"); const connectToDb = require("./db"); -const { loadItems, buildItemTranslationLoader } = require("./loaders"); +const loaders = require("./loaders"); const typeDefs = gql` + enum LayerImageSize { + SIZE_600 + SIZE_300 + SIZE_150 + } + type Item { id: ID! name: String! thumbnailUrl: String! + appearanceOn(speciesId: ID!, colorId: ID!): ItemAppearance + } + + type ItemAppearance { + layers: [ItemAppearanceLayer!]! + } + + type ItemAppearanceLayer { + id: ID! + zone: Zone! + imageUrl(size: LayerImageSize): String + } + + type Zone { + id: ID! + depth: Int! + label: String! } type Query { @@ -21,9 +44,58 @@ const resolvers = { const translation = await itemTranslationLoader.load(item.id); return translation.name; }, + appearanceOn: (item, { speciesId, colorId }) => ({ + itemId: item.id, + speciesId, + colorId, + }), + }, + ItemAppearance: { + layers: async (ia, _, { petTypeLoader, swfAssetLoader }) => { + const petType = await petTypeLoader.load({ + speciesId: ia.speciesId, + colorId: ia.colorId, + }); + const swfAssets = await swfAssetLoader.load({ + itemId: ia.itemId, + bodyId: petType.bodyId, + }); + return swfAssets; + }, + }, + ItemAppearanceLayer: { + zone: async (layer, _, { zoneLoader }) => { + const zone = await zoneLoader.load(layer.zoneId); + return zone; + }, + imageUrl: (layer, { size }) => { + if (!layer.hasImage) { + return null; + } + + const sizeNum = size.split("_")[1]; + + const rid = layer.remoteId; + const paddedId = rid.padStart(12, "0"); + const rid1 = paddedId.slice(0, 3); + const rid2 = paddedId.slice(3, 6); + const rid3 = paddedId.slice(6, 9); + const time = Number(new Date(layer.convertedAt)); + + return `https://impress-asset-images.s3.amazonaws.com/object/${rid1}/${rid2}/${rid3}/${rid}/${sizeNum}x${sizeNum}.png?${time}`; + }, + }, + Zone: { + label: async (zone, _, { zoneTranslationLoader }) => { + const zoneTranslation = await zoneTranslationLoader.load(zone.id); + return zoneTranslation.label; + }, }, Query: { - items: (_, { ids }, { db }) => loadItems(db, ids), + items: async (_, { ids }, { db }) => { + const items = await loaders.loadItems(db, ids); + return items; + }, }, }; @@ -34,7 +106,11 @@ const config = { const db = await connectToDb(); return { db, - itemTranslationLoader: buildItemTranslationLoader(db), + itemTranslationLoader: loaders.buildItemTranslationLoader(db), + petTypeLoader: loaders.buildPetTypeLoader(db), + swfAssetLoader: loaders.buildSwfAssetLoader(db), + zoneLoader: loaders.buildZoneLoader(db), + zoneTranslationLoader: loaders.buildZoneTranslationLoader(db), }; }, }; diff --git a/src/server/index.test.js b/src/server/index.test.js index ee1abf3..eaeb633 100644 --- a/src/server/index.test.js +++ b/src/server/index.test.js @@ -13,7 +13,7 @@ const { query } = createTestClient(new ApolloServer(config)); jest.mock("./db"); let queryFn; let db; -beforeEach(() => { +beforeAll(() => { connectToDb.mockImplementation(async (...args) => { db = await actualConnectToDb(...args); queryFn = jest.spyOn(db, "execute"); @@ -21,71 +21,205 @@ beforeEach(() => { }); }); afterEach(() => { - jest.resetAllMocks(); + queryFn.mockClear(); +}); +afterAll(() => { db.end(); - db = null; }); -it("can load items", async () => { - const res = await query({ - query: gql` - query($ids: [ID!]!) { - items(ids: $ids) { - id - name - thumbnailUrl +describe("Item", () => { + it("loads metadata", async () => { + const res = await query({ + query: gql` + query { + items(ids: ["38913", "38911", "38912"]) { + id + name + thumbnailUrl + } } + `, + }); + + expect(res).toHaveNoErrors(); + expect(res.data).toMatchInlineSnapshot(` + Object { + "items": Array [ + Object { + "id": "38911", + "name": "Zafara Agent Hood", + "thumbnailUrl": "http://images.neopets.com/items/clo_zafara_agent_hood.gif", + }, + Object { + "id": "38912", + "name": "Zafara Agent Robe", + "thumbnailUrl": "http://images.neopets.com/items/clo_zafara_agent_robe.gif", + }, + Object { + "id": "38913", + "name": "Zafara Agent Gloves", + "thumbnailUrl": "http://images.neopets.com/items/clo_zafara_agent_gloves.gif", + }, + ], } - `, - variables: { - ids: [ - 38913, // Zafara Agent Gloves - 38911, // Zafara Agent Hood - 38912, // Zafara Agent Robe - ], - }, + `); + expect(queryFn.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "SELECT * FROM items WHERE id IN (?,?,?)", + Array [ + "38913", + "38911", + "38912", + ], + ], + Array [ + "SELECT * FROM item_translations WHERE item_id IN (?,?,?) AND locale = \\"en\\"", + Array [ + "38911", + "38912", + "38913", + ], + ], + ] + `); }); - expect(res.errors).toBeFalsy(); - expect(res.data).toMatchInlineSnapshot(` - Object { - "items": Array [ - Object { - "id": "38911", - "name": "Zafara Agent Hood", - "thumbnailUrl": "http://images.neopets.com/items/clo_zafara_agent_hood.gif", - }, - Object { - "id": "38912", - "name": "Zafara Agent Robe", - "thumbnailUrl": "http://images.neopets.com/items/clo_zafara_agent_robe.gif", - }, - Object { - "id": "38913", - "name": "Zafara Agent Gloves", - "thumbnailUrl": "http://images.neopets.com/items/clo_zafara_agent_gloves.gif", - }, - ], - } - `); - expect(queryFn.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "SELECT * FROM items WHERE id IN (?,?,?)", - Array [ - "38913", - "38911", - "38912", + it("loads appearance data", async () => { + const res = await query({ + query: gql` + query { + items(ids: ["38912", "38911"]) { + id + name + + appearanceOn(speciesId: "54", colorId: "75") { + layers { + id + imageUrl(size: SIZE_600) + zone { + id + depth + label + } + } + } + } + } + `, + }); + + expect(res).toHaveNoErrors(); + expect(res.data).toMatchInlineSnapshot(` + Object { + "items": Array [ + Object { + "appearanceOn": Object { + "layers": Array [ + Object { + "id": "37129", + "imageUrl": "https://impress-asset-images.s3.amazonaws.com/object/000/000/014/14857/600x600.png?0", + "zone": Object { + "depth": 44, + "id": "40", + "label": "Hat", + }, + }, + ], + }, + "id": "38911", + "name": "Zafara Agent Hood", + }, + Object { + "appearanceOn": Object { + "layers": Array [ + Object { + "id": "37128", + "imageUrl": "https://impress-asset-images.s3.amazonaws.com/object/000/000/014/14856/600x600.png?1587653266000", + "zone": Object { + "depth": 30, + "id": "26", + "label": "Jacket", + }, + }, + ], + }, + "id": "38912", + "name": "Zafara Agent Robe", + }, ], - ], + } + `); + expect(queryFn.mock.calls).toMatchInlineSnapshot(` Array [ - "SELECT * FROM item_translations WHERE item_id IN (?,?,?) AND locale = \\"en\\"", Array [ - 38911, - 38912, - 38913, + "SELECT * FROM items WHERE id IN (?,?)", + Array [ + "38912", + "38911", + ], ], - ], - ] - `); + Array [ + "SELECT * FROM item_translations WHERE item_id IN (?,?) AND locale = \\"en\\"", + Array [ + "38911", + "38912", + ], + ], + Array [ + "SELECT * FROM pet_types WHERE (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?)", + Array [ + "54", + "75", + "54", + "75", + ], + ], + Array [ + "SELECT sa.*, rel.parent_id FROM swf_assets sa + INNER JOIN parents_swf_assets rel ON + rel.parent_type = \\"Item\\" AND + rel.swf_asset_id = sa.id + WHERE (rel.parent_id = ? AND sa.body_id = ?) OR (rel.parent_id = ? AND sa.body_id = ?)", + Array [ + "38911", + "180", + "38912", + "180", + ], + ], + Array [ + "SELECT * FROM zones WHERE id IN (?,?)", + Array [ + "40", + "26", + ], + ], + Array [ + "SELECT * FROM zone_translations WHERE zone_id IN (?,?) AND locale = \\"en\\"", + Array [ + "40", + "26", + ], + ], + ] + `); + }); +}); + +expect.extend({ + toHaveNoErrors(res) { + if (res.errors) { + return { + message: () => + `expected no GraphQL errors, but got:\n ${res.errors}`, + pass: false, + }; + } else { + return { + message: () => `expected GraphQL errors, but there were none`, + pass: true, + }; + } + }, }); diff --git a/src/server/loaders.js b/src/server/loaders.js index c4c4275..d23ebf1 100644 --- a/src/server/loaders.js +++ b/src/server/loaders.js @@ -6,7 +6,7 @@ async function loadItems(db, ids) { `SELECT * FROM items WHERE id IN (${qs})`, ids ); - const entities = rows.map(normalizeProperties); + const entities = rows.map(normalizeRow); return entities; } @@ -17,8 +17,8 @@ const buildItemTranslationLoader = (db) => `SELECT * FROM item_translations WHERE item_id IN (${qs}) AND locale = "en"`, itemIds ); - const entities = rows.map(normalizeProperties); + const entities = rows.map(normalizeRow); const entitiesByItemId = new Map(entities.map((e) => [e.itemId, e])); return itemIds.map( @@ -28,13 +28,108 @@ const buildItemTranslationLoader = (db) => ); }); -function normalizeProperties(row) { +const buildPetTypeLoader = (db) => + new DataLoader(async (speciesAndColorPairs) => { + const conditions = []; + const values = []; + for (const { speciesId, colorId } of speciesAndColorPairs) { + conditions.push("(species_id = ? AND color_id = ?)"); + values.push(speciesId, colorId); + } + + const [rows, _] = await db.execute( + `SELECT * FROM pet_types WHERE ${conditions.join(" OR ")}`, + values + ); + + const entities = rows.map(normalizeRow); + const entitiesBySpeciesAndColorPair = new Map( + entities.map((e) => [`${e.speciesId},${e.colorId}`, e]) + ); + + return speciesAndColorPairs.map(({ speciesId, colorId }) => + entitiesBySpeciesAndColorPair.get(`${speciesId},${colorId}`) + ); + }); + +const buildSwfAssetLoader = (db) => + new DataLoader(async (itemAndBodyPairs) => { + const conditions = []; + const values = []; + for (const { itemId, bodyId } of itemAndBodyPairs) { + conditions.push("(rel.parent_id = ? AND sa.body_id = ?)"); + values.push(itemId, bodyId); + } + + const [rows, _] = await db.execute( + `SELECT sa.*, rel.parent_id FROM swf_assets sa + INNER JOIN parents_swf_assets rel ON + rel.parent_type = "Item" AND + rel.swf_asset_id = sa.id + WHERE ${conditions.join(" OR ")}`, + values + ); + + const entities = rows.map(normalizeRow); + + return itemAndBodyPairs.map(({ itemId, bodyId }) => + entities.filter((e) => e.parentId === itemId && e.bodyId === bodyId) + ); + }); + +const buildZoneLoader = (db) => + new DataLoader(async (zoneIds) => { + const qs = zoneIds.map((_) => "?").join(","); + const [rows, _] = await db.execute( + `SELECT * FROM zones WHERE id IN (${qs})`, + zoneIds + ); + + const entities = rows.map(normalizeRow); + const entitiesById = new Map(entities.map((e) => [e.id, e])); + + return zoneIds.map( + (zoneId) => + entitiesById.get(zoneId) || + new Error(`could not find zone with ID: ${zoneId}`) + ); + }); + +const buildZoneTranslationLoader = (db) => + new DataLoader(async (zoneIds) => { + const qs = zoneIds.map((_) => "?").join(","); + const [rows, _] = await db.execute( + `SELECT * FROM zone_translations WHERE zone_id IN (${qs}) AND locale = "en"`, + zoneIds + ); + + const entities = rows.map(normalizeRow); + const entitiesByZoneId = new Map(entities.map((e) => [e.zoneId, e])); + + return zoneIds.map( + (zoneId) => + entitiesByZoneId.get(zoneId) || + new Error(`could not find translation for zone ${zoneId}`) + ); + }); + +function normalizeRow(row) { const normalizedRow = {}; - for (const [key, value] of Object.entries(row)) { - const normalizedKey = key.replace(/_([a-z])/gi, (m) => m[1].toUpperCase()); - normalizedRow[normalizedKey] = value; + for (let [key, value] of Object.entries(row)) { + key = key.replace(/_([a-z])/gi, (m) => m[1].toUpperCase()); + if (key === "id" || key.endsWith("Id")) { + value = String(value); + } + normalizedRow[key] = value; } return normalizedRow; } -module.exports = { loadItems, buildItemTranslationLoader }; +module.exports = { + loadItems, + buildItemTranslationLoader, + buildPetTypeLoader, + buildSwfAssetLoader, + buildZoneLoader, + buildZoneTranslationLoader, +};