add GQL support for appearance data!
This commit is contained in:
parent
93507cc777
commit
4a61919649
5 changed files with 379 additions and 68 deletions
|
@ -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"
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in a new issue