Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
379 changes: 2 additions & 377 deletions project2/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import type { Pokemon, Ability, PokemonType } from '../src/types/pokemon';
import type { Pokemon } from '../src/types/pokemon';
import db, { seedDatabase } from './db';
import pokemonsData from './pokemons.json' with { type: 'json' };
import { fetchPokemonFromPokeAPI } from './fetchFromPokeAPI';
import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled';
import { resolvers, typeDefs } from './schema';

// Seed database if empty
const pokemonCount = (
db.prepare('SELECT COUNT(*) AS n FROM pokemons').get() as { n: number }
).n;
Expand All @@ -31,381 +31,6 @@ if (pokemonCount === 0) {
}
}

function encodeCursor(value: any, id: number): string {
return Buffer.from(JSON.stringify({ v: value, i: id })).toString('base64');
}

function decodeCursor(cursor: string): { v: any; i: number } {
return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8'));
}

const typeDefs = `#graphql
type Ability {
name: String!
description: String!
short_description: String!
}
type PokemonType {
name: String!
}
type Pokemon {
id: Int!
name: String!
abilities: [Ability!]!
types: [PokemonType!]!
attack: Int!
defence: Int!
special_attack: Int!
special_defence: Int!
speed: Int!
hp: Int!
imageUrl: String!
averageRating: Float
ratingCount: Int!
}
type PokemonPage {
items: [Pokemon!]!
total: Int!
hasNextPage: Boolean!
hasPreviousPage: Boolean!
endCursor: String
}
enum PokemonSortField {
ID
NAME
HP
ATTACK
DEFENCE
SPECIAL_ATTACK
SPECIAL_DEFENCE
SPEED
}
enum SortOrder {
ASC
DESC
}
type Query {
pokemons(
search: String,
first: Int = 10,
after: String,
filter: String,
voterId: ID,
orderBy: String,
orderDir: String
): PokemonPage!
pokemon(id: Int!): Pokemon
}
type Mutation {
ratePokemon(pokemonId: Int!, voterId: ID!, value: Int!): Pokemon!
}
`;

// Whitelist: column names cannot be SQL parameters, so map enum -> column
const SORT_COLUMNS = {
ID: 'id',
NAME: 'name COLLATE NOCASE',
HP: 'hp',
ATTACK: 'attack',
DEFENCE: 'defence',
SPECIAL_ATTACK: 'special_attack',
SPECIAL_DEFENCE: 'special_defence',
SPEED: 'speed',
} as const;

/**
* Aggregated community rating for one Pokémon.
* AVG returns NULL when there are no ratings, which becomes a null
* `averageRating` in GraphQL. COUNT returns 0 in that case.
*/
interface RatingStats {
average: number | null;
votes: number;
}

function getRatingStats(pokemonId: number): RatingStats {
return db
.prepare(
'SELECT AVG(rating) AS average, COUNT(rating) AS votes FROM ratings WHERE pokemon_id = ?'
)
.get(pokemonId) as RatingStats;
}

const resolvers = {
Mutation: {
ratePokemon: (
_parent: unknown,
args: { pokemonId: number; voterId: string; value: number }
) => {
if (!Number.isInteger(args.pokemonId) || args.pokemonId < 1) {
throw new Error('Pokemon ID must be a positive integer.');
}
if (!args.voterId.trim() || args.voterId.length > 128) {
throw new Error('Voter ID must be between 1 and 128 characters.');
}
if (
!Number.isInteger(args.value) ||
args.value < 1 ||
args.value > 5
) {
throw new Error('Rating must be an integer between 1 and 5.');
}
const pokemon = db
.prepare('SELECT * FROM pokemons WHERE id = ?')
.get(args.pokemonId) as Pokemon | undefined;
if (!pokemon) {
throw new Error('Pokemon not found.');
}

db.prepare(`
INSERT INTO ratings (pokemon_id, voter_id, rating)
VALUES (?, ?, ?)
ON CONFLICT (pokemon_id, voter_id)
DO UPDATE SET rating = excluded.rating
`).run(args.pokemonId, args.voterId, args.value);

// Return the Pokémon so clients can update their cache: the field
// resolvers below recompute averageRating and ratingCount.
return pokemon;
},
},
Query: {
pokemons: (
_parent: unknown,
args: {
search?: string;
first?: number;
after?: string;
filter?: string;
voterId?: string;
orderBy?: string;
orderDir?: string;
}
) => {
const first = Math.min(100, Math.max(1, args.first ?? 10));
const search = args.search?.trim() ?? '';
const filter = args.filter?.trim() ?? '';
const orderBy = args.orderBy?.trim() ?? '';
const orderDir =
args.orderDir?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const pattern = `%${search}%`;
const voterId = args.voterId ?? '';
const allowedColumns = [
'id',
'name',
'attack',
'defence',
'special_attack',
'special_defence',
'speed',
'hp',
];
const orderColumn = allowedColumns.includes(orderBy)
? orderBy
: 'id';
const ratingSort = orderBy === 'rating';
const pokemonSource = ratingSort
? 'pokemons p LEFT JOIN ratings r ON r.pokemon_id = p.id AND r.voter_id = ?'
: 'pokemons p';
const sortExpression = ratingSort
? 'COALESCE(r.rating, 0)'
: `p.${orderColumn}`;
const ratingSortColumn = ratingSort
? ', COALESCE(r.rating, 0) AS rating_sort'
: '';

let after: { v: any; i: number } | null = null;
if (args.after) {
try {
after = decodeCursor(args.after);
} catch {
// ignore invalid cursor
}
}

const op = orderDir === 'ASC' ? '>' : '<';
const invOp = orderDir === 'ASC' ? '<' : '>';
const limit = first + 1;

let total: number;
let items: Pokemon[];
let hasPreviousPage = false;

if (filter) {
total = (
db
.prepare(
'SELECT COUNT(DISTINCT p.id) AS n FROM pokemons p JOIN types t ON p.id = t.pokemon_id WHERE p.name LIKE ? AND t.name = ?'
)
.get(pattern, filter) as { n: number }
).n;

if (after) {
hasPreviousPage =
(
db
.prepare(
`SELECT COUNT(DISTINCT p.id) AS n FROM ${pokemonSource} JOIN types t ON p.id = t.pokemon_id WHERE p.name LIKE ? AND t.name = ? AND (${sortExpression}, p.id) ${invOp} (?, ?)`
)
.get(
...(ratingSort ? [voterId] : []),
pattern,
filter,
after.v,
after.i
) as {
n: number;
}
).n > 0;

items = db
.prepare(
`SELECT DISTINCT p.id, p.name, p.attack, p.defence, p.special_attack, p.special_defence, p.speed, p.hp${ratingSortColumn}
FROM ${pokemonSource}
JOIN types t ON p.id = t.pokemon_id
WHERE p.name LIKE ? AND t.name = ? AND (${sortExpression}, p.id) ${op} (?, ?)
ORDER BY ${sortExpression} ${orderDir}, p.id ${orderDir}
LIMIT ?`
)
.all(
...(ratingSort ? [voterId] : []),
pattern,
filter,
after.v,
after.i,
limit
) as Pokemon[];
} else {
items = db
.prepare(
`SELECT DISTINCT p.id, p.name, p.attack, p.defence, p.special_attack, p.special_defence, p.speed, p.hp${ratingSortColumn}
FROM ${pokemonSource}
JOIN types t ON p.id = t.pokemon_id
WHERE p.name LIKE ? AND t.name = ?
ORDER BY ${sortExpression} ${orderDir}, p.id ${orderDir}
LIMIT ?`
)
.all(
...(ratingSort ? [voterId] : []),
pattern,
filter,
limit
) as Pokemon[];
}
} else {
total = (
db
.prepare(
'SELECT COUNT(*) AS n FROM pokemons WHERE name LIKE ?'
)
.get(pattern) as { n: number }
).n;

if (after) {
hasPreviousPage =
(
db
.prepare(
`SELECT COUNT(*) AS n FROM ${pokemonSource} WHERE p.name LIKE ? AND (${sortExpression}, p.id) ${invOp} (?, ?)`
)
.get(
...(ratingSort ? [voterId] : []),
pattern,
after.v,
after.i
) as { n: number }
).n > 0;

items = db
.prepare(
`SELECT p.id, p.name, p.attack, p.defence, p.special_attack, p.special_defence, p.speed, p.hp${ratingSortColumn}
FROM ${pokemonSource}
WHERE p.name LIKE ? AND (${sortExpression}, p.id) ${op} (?, ?)
ORDER BY ${sortExpression} ${orderDir}, p.id ${orderDir}
LIMIT ?`
)
.all(
...(ratingSort ? [voterId] : []),
pattern,
after.v,
after.i,
limit
) as Pokemon[];
} else {
items = db
.prepare(
`SELECT p.id, p.name, p.attack, p.defence, p.special_attack, p.special_defence, p.speed, p.hp${ratingSortColumn}
FROM ${pokemonSource} WHERE p.name LIKE ?
ORDER BY ${sortExpression} ${orderDir}, p.id ${orderDir}
LIMIT ?`
)
.all(
...(ratingSort ? [voterId] : []),
pattern,
limit
) as Pokemon[];
}
}

const hasNextPage = items.length > first;
const resultItems = hasNextPage ? items.slice(0, first) : items;
const endCursor =
resultItems.length > 0
? encodeCursor(
ratingSort
? (
resultItems[
resultItems.length - 1
] as Pokemon & { rating_sort: number }
).rating_sort
: (resultItems[resultItems.length - 1] as any)[
orderColumn
],
resultItems[resultItems.length - 1].id
)
: null;

return {
items: resultItems,
total,
hasNextPage,
hasPreviousPage,
endCursor,
};
},
pokemon: (_parent: unknown, args: { id: number }) =>
(db.prepare('SELECT * FROM pokemons WHERE id = ?').get(args.id) as
Pokemon | undefined) ?? null,
},

// Nested resolvers: fetch children per pokemon via SQL
Pokemon: {
imageUrl: (parent: Pokemon) =>
`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${parent.id}.png`,
types: (parent: Pokemon): PokemonType[] =>
db
.prepare('SELECT name FROM types WHERE pokemon_id = ?')
.all(parent.id) as PokemonType[],
abilities: (parent: Pokemon): Ability[] =>
db
.prepare(
'SELECT name, description, short_description FROM abilities WHERE pokemon_id = ?'
)
.all(parent.id) as Ability[],
averageRating: (parent: Pokemon): number | null =>
getRatingStats(parent.id).average,
ratingCount: (parent: Pokemon): number =>
getRatingStats(parent.id).votes,
},
};

const server = new ApolloServer({
typeDefs,
resolvers,
Expand Down
Loading