add GQL support for appearance data!

This commit is contained in:
Matt Dunn-Rankin 2020-04-23 01:08:00 -07:00
parent 93507cc777
commit 4a61919649
5 changed files with 379 additions and 68 deletions

View file

@ -27,7 +27,8 @@
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "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": { "eslintConfig": {
"extends": "react-app" "extends": "react-app"

View file

@ -1,2 +1,7 @@
GRANT SELECT ON openneo_impress.items TO impress2020; GRANT SELECT ON openneo_impress.items TO impress2020;
GRANT SELECT ON openneo_impress.item_translations 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;

View file

@ -1,13 +1,36 @@
const { gql } = require("apollo-server"); const { gql } = require("apollo-server");
const connectToDb = require("./db"); const connectToDb = require("./db");
const { loadItems, buildItemTranslationLoader } = require("./loaders"); const loaders = require("./loaders");
const typeDefs = gql` const typeDefs = gql`
enum LayerImageSize {
SIZE_600
SIZE_300
SIZE_150
}
type Item { type Item {
id: ID! id: ID!
name: String! name: String!
thumbnailUrl: 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 { type Query {
@ -21,9 +44,58 @@ const resolvers = {
const translation = await itemTranslationLoader.load(item.id); const translation = await itemTranslationLoader.load(item.id);
return translation.name; 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: { 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(); const db = await connectToDb();
return { return {
db, db,
itemTranslationLoader: buildItemTranslationLoader(db), itemTranslationLoader: loaders.buildItemTranslationLoader(db),
petTypeLoader: loaders.buildPetTypeLoader(db),
swfAssetLoader: loaders.buildSwfAssetLoader(db),
zoneLoader: loaders.buildZoneLoader(db),
zoneTranslationLoader: loaders.buildZoneTranslationLoader(db),
}; };
}, },
}; };

View file

@ -13,7 +13,7 @@ const { query } = createTestClient(new ApolloServer(config));
jest.mock("./db"); jest.mock("./db");
let queryFn; let queryFn;
let db; let db;
beforeEach(() => { beforeAll(() => {
connectToDb.mockImplementation(async (...args) => { connectToDb.mockImplementation(async (...args) => {
db = await actualConnectToDb(...args); db = await actualConnectToDb(...args);
queryFn = jest.spyOn(db, "execute"); queryFn = jest.spyOn(db, "execute");
@ -21,71 +21,205 @@ beforeEach(() => {
}); });
}); });
afterEach(() => { afterEach(() => {
jest.resetAllMocks(); queryFn.mockClear();
});
afterAll(() => {
db.end(); db.end();
db = null;
}); });
it("can load items", async () => { describe("Item", () => {
const res = await query({ it("loads metadata", async () => {
query: gql` const res = await query({
query($ids: [ID!]!) { query: gql`
items(ids: $ids) { query {
id items(ids: ["38913", "38911", "38912"]) {
name id
thumbnailUrl 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: { expect(queryFn.mock.calls).toMatchInlineSnapshot(`
ids: [ Array [
38913, // Zafara Agent Gloves Array [
38911, // Zafara Agent Hood "SELECT * FROM items WHERE id IN (?,?,?)",
38912, // Zafara Agent Robe Array [
], "38913",
}, "38911",
"38912",
],
],
Array [
"SELECT * FROM item_translations WHERE item_id IN (?,?,?) AND locale = \\"en\\"",
Array [
"38911",
"38912",
"38913",
],
],
]
`);
}); });
expect(res.errors).toBeFalsy(); it("loads appearance data", async () => {
expect(res.data).toMatchInlineSnapshot(` const res = await query({
Object { query: gql`
"items": Array [ query {
Object { items(ids: ["38912", "38911"]) {
"id": "38911", id
"name": "Zafara Agent Hood", name
"thumbnailUrl": "http://images.neopets.com/items/clo_zafara_agent_hood.gif",
}, appearanceOn(speciesId: "54", colorId: "75") {
Object { layers {
"id": "38912", id
"name": "Zafara Agent Robe", imageUrl(size: SIZE_600)
"thumbnailUrl": "http://images.neopets.com/items/clo_zafara_agent_robe.gif", zone {
}, id
Object { depth
"id": "38913", label
"name": "Zafara Agent Gloves", }
"thumbnailUrl": "http://images.neopets.com/items/clo_zafara_agent_gloves.gif", }
}, }
], }
} }
`); `,
expect(queryFn.mock.calls).toMatchInlineSnapshot(` });
Array [
Array [ expect(res).toHaveNoErrors();
"SELECT * FROM items WHERE id IN (?,?,?)", expect(res.data).toMatchInlineSnapshot(`
Array [ Object {
"38913", "items": Array [
"38911", Object {
"38912", "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 [ Array [
"SELECT * FROM item_translations WHERE item_id IN (?,?,?) AND locale = \\"en\\"",
Array [ Array [
38911, "SELECT * FROM items WHERE id IN (?,?)",
38912, Array [
38913, "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,
};
}
},
}); });

View file

@ -6,7 +6,7 @@ async function loadItems(db, ids) {
`SELECT * FROM items WHERE id IN (${qs})`, `SELECT * FROM items WHERE id IN (${qs})`,
ids ids
); );
const entities = rows.map(normalizeProperties); const entities = rows.map(normalizeRow);
return entities; return entities;
} }
@ -17,8 +17,8 @@ const buildItemTranslationLoader = (db) =>
`SELECT * FROM item_translations WHERE item_id IN (${qs}) AND locale = "en"`, `SELECT * FROM item_translations WHERE item_id IN (${qs}) AND locale = "en"`,
itemIds itemIds
); );
const entities = rows.map(normalizeProperties);
const entities = rows.map(normalizeRow);
const entitiesByItemId = new Map(entities.map((e) => [e.itemId, e])); const entitiesByItemId = new Map(entities.map((e) => [e.itemId, e]));
return itemIds.map( 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 = {}; const normalizedRow = {};
for (const [key, value] of Object.entries(row)) { for (let [key, value] of Object.entries(row)) {
const normalizedKey = key.replace(/_([a-z])/gi, (m) => m[1].toUpperCase()); key = key.replace(/_([a-z])/gi, (m) => m[1].toUpperCase());
normalizedRow[normalizedKey] = value; if (key === "id" || key.endsWith("Id")) {
value = String(value);
}
normalizedRow[key] = value;
} }
return normalizedRow; return normalizedRow;
} }
module.exports = { loadItems, buildItemTranslationLoader }; module.exports = {
loadItems,
buildItemTranslationLoader,
buildPetTypeLoader,
buildSwfAssetLoader,
buildZoneLoader,
buildZoneTranslationLoader,
};