Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ To support larger datasets, searching, filtering, and pagination are handled by

The city dataset provides a larger set of records for evaluating database queries and scalability.

For both `countries` and `cities`, the backend caps GraphQL `limit` at 100 results per request (default 20) to prevent oversized queries. Search input escapes `%`, `_`, and backslashes before using `ILIKE`, so those characters are matched literally rather than acting as SQL wildcards. Queries still use parameterized values.

For deployment details, see [Deployment on the NTNU VM](docs/backendDeployment.md).

## Pagination and Caching
Expand Down
13 changes: 7 additions & 6 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFile } from 'node:fs/promises';
import { ApolloServer } from '@apollo/server';
import { escapeLikeSearch, normalizeLimit } from './queryInput.ts';

export interface Db {
query: (sql: string, params?: unknown[]) => Promise<{ rows: Record<string, unknown>[] }>;
Expand Down Expand Up @@ -28,14 +29,14 @@ export function createResolvers(pool: Db) {

countries: async (_parent, args) => {
const filters = `
WHERE ($1::text IS NULL OR name ILIKE '%' || $1 || '%')
WHERE ($1::text IS NULL OR name ILIKE '%' || $1 || '%' ESCAPE E'\\\\')
AND ($2::text IS NULL OR continent = $2)
`;
const filterValues = [args.search ?? null, args.continent ?? null];
const filterValues = [escapeLikeSearch(args.search), args.continent ?? null];

const itemsResult = await pool.query(
`SELECT ${COUNTRY_COLUMNS} FROM country ${filters} ORDER BY name LIMIT $3 OFFSET $4`,
[...filterValues, args.limit, args.offset]
[...filterValues, normalizeLimit(args.limit), args.offset]
);
const countResult = await pool.query(
`SELECT count(*) FROM country ${filters}`,
Expand All @@ -57,14 +58,14 @@ export function createResolvers(pool: Db) {

cities: async (_parent, args) => {
const filters = `
WHERE ($1::text IS NULL OR name ILIKE '%' || $1 || '%')
WHERE ($1::text IS NULL OR name ILIKE '%' || $1 || '%' ESCAPE E'\\\\')
AND ($2::text IS NULL OR country_code = $2)
`;
const filterValues = [args.search ?? null, args.countryCode ?? null];
const filterValues = [escapeLikeSearch(args.search), args.countryCode ?? null];

const itemsResult = await pool.query(
`SELECT ${CITY_COLUMNS} FROM city ${filters} ORDER BY population DESC LIMIT $3 OFFSET $4`,
[...filterValues, args.limit, args.offset]
[...filterValues, normalizeLimit(args.limit), args.offset]
);
const countResult = await pool.query(`SELECT count(*) FROM city ${filters}`, filterValues);

Expand Down
13 changes: 13 additions & 0 deletions backend/src/queryInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** Maximum number of results returned per request. */
export const MAX_PAGE_LIMIT = 100;
const DEFAULT_PAGE_LIMIT = 20;

/** Restrict page sizes to between 1 and 100. */
export function normalizeLimit(limit: number | null | undefined): number {
return Math.min(Math.max(limit ?? DEFAULT_PAGE_LIMIT, 1), MAX_PAGE_LIMIT);
}

/** Escape special characters used by PostgreSQL ILIKE. */
export function escapeLikeSearch(search: string | null | undefined): string | null {
return search == null ? null : search.replace(/[\\%_]/g, '\\$&');
}
23 changes: 14 additions & 9 deletions backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { GraphQLError } from 'graphql';
import { pool } from './db.ts';
import { escapeLikeSearch, normalizeLimit } from './queryInput.ts';

const PORT = Number(process.env.PORT ?? 3001);
const HOST = process.env.HOST ?? '0.0.0.0';
Expand Down Expand Up @@ -34,7 +35,7 @@ const SORT_ORDERS = new Map([
]);
const MAX_RANDOM_COUNTRIES = 50;

const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

const typeDefs = await readFile(new URL('./schema.graphql', import.meta.url), 'utf-8');

Expand All @@ -59,9 +60,9 @@ const resolvers = {
const sortExpression = args.sortBy === 'NAME' ? 'name COLLATE "C"' : sortColumn;
const sortOrder = SORT_ORDERS.get(args.sortOrder) ?? 'ASC';

// Bind search text as a value; ILIKE provides case-insensitive substring matching.
// Escape ILIKE wildcard characters while keeping search values parameterized.
const filters = `
WHERE ($1::text IS NULL OR name ILIKE '%' || $1 || '%')
WHERE ($1::text IS NULL OR name ILIKE '%' || $1 || '%' ESCAPE E'\\\\')
AND ($2::text IS NULL OR continent = $2)
AND ($3::text IS NULL OR $3 = ANY(languages))
AND (
Expand Down Expand Up @@ -95,7 +96,7 @@ const resolvers = {
`;

const filterValues = [
args.search ?? null,
escapeLikeSearch(args.search),
args.continent ?? null,
args.language ?? null,
args.status ?? null,
Expand All @@ -109,7 +110,7 @@ const resolvers = {
${filters}
ORDER BY ${sortExpression} ${sortOrder} NULLS LAST, code ASC
LIMIT $7 OFFSET $8`,
[...filterValues, args.limit ?? 20, args.offset ?? 0]
[...filterValues, normalizeLimit(args.limit), args.offset ?? 0]
);

// Count all filtered matches separately so totalCount does not shrink with each page.
Expand Down Expand Up @@ -144,9 +145,9 @@ const resolvers = {
const sortColumn = CITY_SORT_COLUMNS.get(args.sortBy) ?? 'population';
const sortOrder = SORT_ORDERS.get(args.sortOrder) ?? 'DESC';

// ILIKE makes city-name matching case-insensitive; values remain bound parameters.
// Escape ILIKE wildcard characters while keeping search values parameterized.
const filters = `
WHERE ($1::text IS NULL OR name ILIKE '%' || $1 || '%')
WHERE ($1::text IS NULL OR name ILIKE '%' || $1 || '%' ESCAPE E'\\\\')
AND ($2::text IS NULL OR country_code = $2)
AND (
$3::text IS NULL
Expand All @@ -156,7 +157,11 @@ const resolvers = {
)
`;

const filterValues = [args.search ?? null, args.countryCode ?? null, args.continent ?? null];
const filterValues = [
escapeLikeSearch(args.search),
args.countryCode ?? null,
args.continent ?? null,
];

// Apply pagination after filtering/sorting, and keep the total independent of the page.
const itemsResult = await pool.query(
Expand All @@ -165,7 +170,7 @@ const resolvers = {
${filters}
ORDER BY ${sortColumn} ${sortOrder} NULLS LAST, id ASC
LIMIT $4 OFFSET $5`,
[...filterValues, args.limit ?? 20, args.offset ?? 0]
[...filterValues, normalizeLimit(args.limit), args.offset ?? 0]
);

// Return the filtered total, not just the current LIMIT/OFFSET page.
Expand Down
60 changes: 59 additions & 1 deletion backend/test/graphql.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createServer } from '../src/app.ts';
import { countryFixtures, createTestDb } from './testDb.ts';
import { MAX_PAGE_LIMIT } from '../src/queryInput.ts';

let db: Awaited<ReturnType<typeof createTestDb>>;
let server: ReturnType<typeof createServer>;
const observedPageLimits: number[] = [];

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = (result: { data?: unknown }) => result.data as any;
Expand Down Expand Up @@ -32,7 +34,14 @@ async function countries(variables: Record<string, unknown> = {}) {

beforeAll(async () => {
db = await createTestDb();
server = createServer(db);
server = createServer({
query: async (sql, params) => {
if (sql.includes(' LIMIT ')) {
observedPageLimits.push(Number(params?.at(-2)));
}
return db.query(sql, params);
},
});
await server.start();
});

Expand Down Expand Up @@ -102,6 +111,21 @@ describe('countries query', () => {
expect(result).toEqual({ items: [], totalCount: 0 });
});

it('caps excessively large country page requests on the server', async () => {
await countries({ limit: 100000 });
expect(observedPageLimits.at(-1)).toBe(MAX_PAGE_LIMIT);
});

it('searches for literal percent and underscore characters in countries', async () => {
const percent = await countries({ search: '%', limit: 20 });
expect(percent.items.map((c) => c.name)).toEqual(['Rate 100%']);
expect(percent.totalCount).toBe(1);

const underscore = await countries({ search: '_', limit: 20 });
expect(underscore.items.map((c) => c.name)).toEqual(['Code_A']);
expect(underscore.totalCount).toBe(1);
});

it('returns nothing for a search without matches', async () => {
expect(await countries({ search: 'zzzz', limit: 20, offset: 0 })).toEqual({
items: [],
Expand Down Expand Up @@ -151,6 +175,40 @@ describe('cities query', () => {
expect(page.items).toEqual([{ id: '2', name: 'Bergen', country: { code: 'XA' } }]);
});

it('caps excessively large city page requests on the server', async () => {
const result = await run(CITIES, { limit: 100000 });
expect(result.errors).toBeUndefined();
expect(observedPageLimits.at(-1)).toBe(MAX_PAGE_LIMIT);
});

it('treats percent and underscore as literal characters in city searches', async () => {
// Add both literal matches and lookalikes that must not be included.
const names = ['100% City', '100X City', 'A_City', 'AXCity'];
for (const [index, name] of names.entries()) {
await db.query(
`INSERT INTO city (id, name, latitude, longitude, country_code, population)
VALUES ($1, $2, 0, 0, 'XA', 1)`,
[index + 4, name]
);
}

try {
for (const [search, expectedName] of [
['%', '100% City'],
['_', 'A_City'],
]) {
const result = await run(CITIES, { search });
expect(result.errors).toBeUndefined();
expect(data(result).cities.items.map((city: { name: string }) => city.name)).toEqual([
expectedName,
]);
expect(data(result).cities.totalCount).toBe(1);
}
} finally {
await db.query('DELETE FROM city WHERE id >= $1', [4]);
}
});

it('searches case-insensitively', async () => {
const result = await run(CITIES, { search: 'OSL', limit: 10, offset: 0 });
expect(data(result).cities.items.map((c: { name: string }) => c.name)).toEqual(['Oslo']);
Expand Down
7 changes: 6 additions & 1 deletion backend/test/testDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ export const countryFixtures = Array.from({ length: 25 }, (_, i) => {
};
});

// Names that exercise case-insensitive search.
// Names that exercise case-insensitive and literal wildcard searches.
countryFixtures[0].name = 'Norway';
countryFixtures[1].name = 'Northland';
countryFixtures[2].name = 'Aland';
countryFixtures[3].name = 'Rate 100%';
countryFixtures[4].name = 'Rate 100X';
countryFixtures[5].name = 'Code_A';
countryFixtures[6].name = 'CodeXA';

export const cityFixtures = [
{ id: 1, name: 'Oslo', latitude: 59.9, longitude: 10.7, country_code: 'XA', population: 700000 },
Expand Down Expand Up @@ -67,6 +71,7 @@ export async function createTestDb(): Promise<Db & { close: () => Promise<void>
[c.code, c.name, c.capital, c.continent, c.languages, c.population, c.area, c.flagUrl]
);
}

for (const c of cityFixtures) {
await db.query(
`INSERT INTO city (id, name, latitude, longitude, country_code, population)
Expand Down