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
151 changes: 143 additions & 8 deletions backend/backend-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,22 +53,36 @@ game(id: ID!): Game
Henter en side med spill, med valgfritt søk og filtre. Resultatet er sortert etter popularitet.

```graphql
games(search: String, filter: GameFilter, first: Int = 20): GameConnection!
games(
search: String
filter: GameFilter
sort: GameSort! = POPULARITY
first: Int
after: String
last: Int
before: String
): GameConnection!
```

#### Argumenter

| Navn | Type | Default | Beskrivelse |
| -------- | --------------------------- | ------- | --------------------------------------------------------------------------------------------------------- |
| `search` | `String` | — | Søk i tittel og alternative navn. Skiller ikke mellom store og små bokstaver, og matcher deler av navnet. |
| `filter` | [`GameFilter`](#gamefilter) | — | Filtrering på sjanger, plattform og utgivelsesår. |
| `first` | `Int` | `20` | Antall spill på siden. Begrenset til 1–50. |
| Navn | Type | Default | Beskrivelse |
| -------- | --------------------------- | ------------ | ----------------------------------------------------------------------------- |
| `search` | `String` | — | Søk i tittel og alternative navn. Skiller ikke mellom store og små bokstaver. |
| `filter` | [`GameFilter`](#gamefilter) | — | Filtrering på sjanger, plattform og utgivelsesår. |
| `sort` | [`GameSort!`](#gamesort) | `POPULARITY` | Sorteringsrekkefølge. |
| `first` | `Int` | `20` | Antall spill fremover fra `after`. Begrenses til 1–50. |
| `after` | `String` | — | Cursor fra `pageInfo.endCursor`. Henter spillene etter denne. |
| `last` | `Int` | — | Antall spill bakover fra `before`. Begrenses til 1–50. |
| `before` | `String` | — | Cursor fra `pageInfo.startCursor`. Henter spillene før denne. |

Alle argumenter er valgfrie og kan kombineres.
`first`/`after` og `last`/`before` kan ikke kombineres. `last` uten `before` henter siste side.

#### Returnerer

[`GameConnection`](#gameconnection) med spillene på siden og totalt antall treff.
[`GameConnection!`](#gameconnection). `totalCount` er antall treff for søk og filter, uavhengig av cursor. En cursor er bare gyldig for sorteringen den ble laget med.

Gir feilen `BAD_USER_INPUT` hvis begge retningene brukes samtidig, eller hvis cursoren er ugyldig eller tilhører en annen sortering.

#### Eksempel: søk

Expand Down Expand Up @@ -136,6 +150,127 @@ Action- og RPG-spill på PC, utgitt 2015–2020:
}
```

#### Eksempel: paginering fremover

```graphql
{
games(sort: POPULARITY, first: 2) {
pageInfo {
hasNextPage
hasPrevPage
startCursor
endCursor
}
edges {
node {
name
}
}
}
}
```

```json
{
"data": {
"games": {
"pageInfo": {
"hasNextPage": true,
"hasPrevPage": false,
"startCursor": "eyJzIjoiUE9QVUxBUklUWSIsImsiOiIyMjYzNSIsImlkIjozNDk4fQ",
"endCursor": "eyJzIjoiUE9QVUxBUklUWSIsImsiOiIyMjI4NSIsImlkIjozMzI4fQ"
},
"edges": [
{ "node": { "name": "Grand Theft Auto V" } },
{ "node": { "name": "The Witcher 3: Wild Hunt" } }
]
}
}
}
```

Neste side hentes med `endCursor` som `after`:

```graphql
{
games(
sort: POPULARITY
first: 2
after: "eyJzIjoiUE9QVUxBUklUWSIsImsiOiIyMjI4NSIsImlkIjozMzI4fQ"
) {
pageInfo {
hasNextPage
hasPrevPage
startCursor
}
edges {
node {
name
}
}
}
}
```

```json
{
"data": {
"games": {
"pageInfo": {
"hasNextPage": true,
"hasPrevPage": true,
"startCursor": "eyJzIjoiUE9QVUxBUklUWSIsImsiOiIyMDkyNiIsImlkIjo0MjAwfQ"
},
"edges": [
{ "node": { "name": "Portal 2" } },
{ "node": { "name": "Counter-Strike: Global Offensive" } }
]
}
}
}
```

#### Eksempel: paginering bakover

Forrige side hentes med `startCursor` som `before`:

```graphql
{
games(
sort: POPULARITY
last: 2
before: "eyJzIjoiUE9QVUxBUklUWSIsImsiOiIyMDkyNiIsImlkIjo0MjAwfQ"
) {
pageInfo {
hasNextPage
hasPrevPage
}
edges {
node {
name
}
}
}
}
```

```json
{
"data": {
"games": {
"pageInfo": {
"hasNextPage": true,
"hasPrevPage": false
},
"edges": [
{ "node": { "name": "Grand Theft Auto V" } },
{ "node": { "name": "The Witcher 3: Wild Hunt" } }
]
}
}
}
```

---

### genres
Expand Down
37 changes: 37 additions & 0 deletions backend/src/cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { GraphQLError } from 'graphql';
import type { GameSort } from './resolvers.js';

type Cursor = { s: GameSort; k: string; id: number };

const error = () => new GraphQLError('Invalid cursor', { extensions: { code: 'BAD_USER_INPUT' } });

// konverter cursor til base64url
export function encodeCursor(cursor: Cursor) {
return Buffer.from(JSON.stringify(cursor)).toString('base64url');
}

// konverter base64url til cursor-JSON
export function decodeCursor(str: string, sort: GameSort) {
let cursor: Cursor;

try {
cursor = JSON.parse(Buffer.from(str, 'base64url').toString());
} catch {
throw error();
}

if (typeof cursor !== 'object' || cursor === null) {
throw error();
}

if (cursor.s !== sort) {
throw error();
}
if (typeof cursor.k !== 'string') {
throw error();
}
if (!Number.isSafeInteger(cursor.id) || cursor.id <= 0) {
throw error();
}
return cursor;
}
89 changes: 79 additions & 10 deletions backend/src/resolvers.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { GraphQLError } from 'graphql/error';
import { decodeCursor, encodeCursor } from './cursor.js';
import { pool } from './db.js';

// type definitions
type GamesArgs = {
search?: string | null;
filter?: GameFilter | null;
sort: GameSort;
first: number;
first?: number | null;
after?: string | null;
last?: number | null;
before?: string | null;
};

type GameFilter = {
Expand Down Expand Up @@ -75,13 +80,14 @@ const GAMES_WHERE = `
// SQL sorting med order by
// denne variablen mapper en enum til en gitt order
// som kan brukes i SQL-spørringen
const ORDER_BY = {
POPULARITY: 'added DESC, id DESC',
NEWEST: 'released DESC NULLS LAST, id DESC',
METACRITIC: 'metacritic DESC NULLS LAST, id DESC',
NAME: 'name ASC, id ASC',
const SORTS = {
POPULARITY: { key: 'added', cast: 'int', dir: 'DESC' },
NEWEST: { key: "COALESCE(released, '-infinity'::date)", cast: 'date', dir: 'DESC' },
METACRITIC: { key: 'COALESCE(metacritic, -1)', cast: 'int', dir: 'DESC' },
// ?? dette fikser noe sortering i databasen
NAME: { key: 'name COLLATE "und-x-icu"', cast: 'text', dir: 'ASC' },
} as const;
type GameSort = keyof typeof ORDER_BY;
export type GameSort = keyof typeof SORTS;

// SQL queries
const SQL_COUNT = `
Expand All @@ -101,13 +107,27 @@ const SQL_GAME = `
WHERE id = $1
`;

function sortSql(sort: GameSort, reverse: boolean) {
const { key, cast, dir } = SORTS[sort];
const order = reverse ? (dir === 'DESC' ? 'ASC' : 'DESC') : dir;
const op = order === 'DESC' ? '<' : '>';

return {
keyCol: `${key}::text as "sortKey"`,
orderBy: `${key} ${order}, id ${order}`,
cursorWhere: `($8::bigint IS NULL OR (${key}, id) ${op} ($7::${cast}, $8::bigint))`,
};
}

// denne må være en funksjon for å kunne ta inn en variabel sort
// finnes sikkert en bedre måte...
function sqlGames(sort: GameSort) {
function sqlGames(sort: GameSort, reverse: boolean) {
const { keyCol, orderBy, cursorWhere } = sortSql(sort, reverse);
return `
SELECT ${GAME_COLS} FROM games
SELECT ${GAME_COLS}, ${keyCol} FROM games
${GAMES_WHERE}
ORDER BY ${ORDER_BY[sort]}
AND ${cursorWhere}
ORDER BY ${orderBy}
LIMIT $6
`;
}
Expand Down Expand Up @@ -152,6 +172,11 @@ async function game(_parent: unknown, args: { id: string }) {
return res.rows[0] ?? null;
}

/*
!! IKKE FJERNE DENNE !!
beholder intill videre til vi ser at pagineringen faktisk fungerer
async function games(_parent: unknown, args: GamesArgs) {
const values = filterValues(args);
const first = Math.min(Math.max(args.first, 1), MAX_PAGE_SIZE);
Expand All @@ -163,6 +188,50 @@ async function games(_parent: unknown, args: GamesArgs) {
values,
};
}
*/

async function games(_parent: unknown, args: GamesArgs) {
const hasForwardArgs = args.first != null || args.after != null;
const hasReverseArgs = args.last != null || args.before != null;
if (hasForwardArgs && hasReverseArgs) {
throw new GraphQLError('Use either first/after or last/before', {
extensions: { code: 'BAD_USER_INPUT' },
});
}
const reverse = hasReverseArgs;

const limit = Math.min(Math.max(args.first ?? args.last ?? 20, 1), MAX_PAGE_SIZE);
const raw = args.after ?? args.before;
const cursor = raw ? decodeCursor(raw, args.sort) : null;

const values = filterValues(args);
const res = await pool.query(sqlGames(args.sort, reverse), [
...values,
limit + 1,
cursor?.k ?? null,
cursor?.id ?? null,
]);

const hasMore = res.rows.length > limit;
const rows = res.rows.slice(0, limit);
if (reverse) rows.reverse();

const edges = rows.map((row) => ({
node: row,
cursor: encodeCursor({ s: args.sort, k: row.sortKey, id: Number(row.id) }),
}));

return {
edges,
pageInfo: {
hasNextPage: reverse ? args.before != null : hasMore,
hasPrevPage: reverse ? hasMore : args.after != null,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges.at(-1)?.cursor ?? null,
},
values,
};
}

async function totalCount(parent: { values: unknown[] }) {
return (await pool.query(SQL_COUNT, parent.values)).rows[0].count;
Expand Down
13 changes: 12 additions & 1 deletion backend/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export const typeDefs = `#graphql
"""
type PageInfo {
hasNextPage: Boolean!
hasPrevPage: Boolean!
startCursor: String
endCursor: String
}
Expand All @@ -89,7 +91,16 @@ export const typeDefs = `#graphql
"""
type Query {
game(id: ID!): Game
games(search: String, filter: GameFilter, sort: GameSort!=POPULARITY first: Int!=20): GameConnection!
games(
search: String,
filter: GameFilter,
sort: GameSort!=POPULARITY
first: Int,
after: String,
last: Int,
before: String
): GameConnection!
genres: [Facet!]!
platforms: [Facet!]!
}
Expand Down
2 changes: 1 addition & 1 deletion db/indexes.sql
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ CREATE INDEX games_search_trgm ON games USING gin (search_names gin_trgm_ops);
CREATE INDEX games_added_id ON games (added DESC, id DESC);
CREATE INDEX games_released_id ON games ((COALESCE(released, '-infinity'::date)) DESC, id DESC);
CREATE INDEX games_meta_id ON games ((COALESCE(metacritic, -1)) DESC, id DESC);
CREATE INDEX games_name_id ON games (name, id);
CREATE INDEX games_name_id ON games ((name COLLATE "und-x-icu"), id);

CREATE INDEX game_genres_rev ON game_genres (genre_id, game_id);
CREATE INDEX game_platforms_rev ON game_platforms (platform_id, game_id);
Expand Down
9 changes: 6 additions & 3 deletions db/load.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,12 @@ def export_csvs(out: Path) -> None:
con = duckdb.connect()
con.create_function("unescape", html.unescape, ["VARCHAR"], "VARCHAR")
con.execute(
f"""CREATE VIEW src AS
SELECT * FROM read_parquet('{PARQUET}')
WHERE added > 0 AND name IS NOT NULL AND trim(name) <> ''"""
f"""CREATE VIEW src AS
SELECT * FROM (
SELECT * REPLACE (trim(regexp_replace(name, '\\p{{Cf}}', '', 'g')) AS name)
FROM read_parquet('{PARQUET}')
)
WHERE added > 0 AND regexp_matches(name, '[\\p{{L}}\\p{{N}}]')"""
)
con.execute(
f"""COPY (
Expand Down
Loading