Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
14 changes: 13 additions & 1 deletion server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,14 @@ Open `http://127.0.0.1:3001/` and run:
query {
health
databaseStatus
venues(offset: 0, limit: 10) {
venueTypes
venues(
search: "Midtbyen"
types: [PUB, BAR]
sort: RATING
offset: 0
limit: 10
) {
totalCount
items {
id
Expand All @@ -122,6 +129,11 @@ query {
}
```

`search` is case-insensitive and matches venue names, areas and types. Multiple
`types` are combined with OR. `totalCount` is the size of the complete filtered
result, while `items` contains only the requested page. Omitting these optional
arguments preserves the original name-sorted catalog query.

`health` returns `ok`; `databaseStatus` returns `connected`. The model document
contains an example requesting all 16 fields. On the VM, the compiled server is
run by the persistent `t07-project2-api.service`. Apache proxies the public
Expand Down
68 changes: 63 additions & 5 deletions server/src/graphql/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,32 @@ export const typeDefs = `
totalCount: Int!
}
enum VenueType {
BAR
PUB
NIGHTCLUB
}
enum VenueSort {
NAME
RATING
}
type Query {
"""Simple API check that does not require a database query."""
health: String!
"""Checks whether the backend can reach its PostgreSQL database."""
databaseStatus: String!
venues(offset: Int = 0, limit: Int = 10): VenuePage!
venues(
search: String
types: [VenueType!]
sort: VenueSort = NAME
offset: Int = 0
limit: Int = 10
): VenuePage!
venueTypes: [VenueType!]!
venue(id: ID!): Venue
}
`
Expand All @@ -60,10 +78,34 @@ const venueColumns = `id, name, type, city, area, address, latitude, longitude,
opening_hours AS "openingHours", tags, image_url AS "imageUrl", website_url AS "websiteUrl"`

type VenuePageArguments = {
search?: string | null
types?: VenueType[] | null
sort?: VenueSort
offset?: number
limit?: number
}

type VenueType = 'BAR' | 'PUB' | 'NIGHTCLUB'
type VenueSort = 'NAME' | 'RATING'

const venueFilters = `
WHERE (
$1::text IS NULL
OR name ILIKE $1 ESCAPE '\\'
OR area ILIKE $1 ESCAPE '\\'
OR type ILIKE $1 ESCAPE '\\'
)
AND (
cardinality($2::text[]) = 0
OR type = ANY($2::text[])
)`

function toSearchPattern(search?: string | null) {
const value = search?.trim()
if (!value) return null
return `%${value.replace(/[\\%_]/g, '\\$&')}%`
}

function validatePagination({ offset = 0, limit = 10 }: VenuePageArguments) {
if (offset < 0) {
throw new GraphQLError('offset must be zero or greater.', {
Expand All @@ -89,21 +131,37 @@ export const createResolvers = (database: SqlExecutor) => ({
},
venues: async (_parent: unknown, args: VenuePageArguments) => {
const { offset, limit } = validatePagination(args)
const search = toSearchPattern(args.search)
const types = args.types ?? []
const orderBy =
args.sort === 'RATING'
? 'rating DESC NULLS LAST, LOWER(name), id'
: 'LOWER(name), id'
const countResult = await database.query(
'SELECT COUNT(*)::int AS "totalCount" FROM venues',
`SELECT COUNT(*)::int AS "totalCount"
FROM venues
${venueFilters}`,
[search, types],
)
const { rows } = await database.query(
`SELECT ${venueColumns}
FROM venues
ORDER BY name, id
LIMIT $1 OFFSET $2`,
[limit, offset],
${venueFilters}
ORDER BY ${orderBy}
LIMIT $3 OFFSET $4`,
[search, types, limit, offset],
)
return {
items: rows,
totalCount: Number(countResult.rows[0]?.totalCount ?? 0),
}
},
venueTypes: async () => {
const { rows } = await database.query(
'SELECT DISTINCT type FROM venues ORDER BY type',
)
return rows.map(({ type }) => type)
},
venue: async (_parent: unknown, args: { id: string }) => {
if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
Expand Down
183 changes: 183 additions & 0 deletions server/tests/graphqlCatalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { ApolloServer } from '@apollo/server'
import { PGlite } from '@electric-sql/pglite'
import { MigrationBuilder } from 'node-pg-migrate'
import { up as createVenueTables } from '../migrations/1791904375317_create-venues.js'
import { up as completeModel } from '../migrations/1791907200000_complete-venue-model.js'
import { importVenues, type ImportDatabase } from '../src/data/importVenues.js'
import { createResolvers, typeDefs } from '../src/graphql/schema.js'
import { venueFixture } from './fixtures.js'

async function migrate(db: PGlite, migration: typeof createVenueTables) {
const builder = new MigrationBuilder(
{} as ConstructorParameters<typeof MigrationBuilder>[0],
undefined,
false,
console,
)
await migration(builder)
await db.exec(builder.getSql())
}

function adapter(db: PGlite): ImportDatabase {
return {
connect: async () => ({
query: (sql, values) => db.query<Record<string, unknown>>(sql, values),
release() {},
}),
}
}

async function setupCatalog() {
const db = new PGlite()
await migrate(db, createVenueTables)
await migrate(db, completeModel)
await importVenues(adapter(db), [
venueFixture({
sourceId: 'openstreetmap:n:1',
name: 'Alpha Taproom',
type: 'PUB',
area: 'Midtbyen',
}),
venueFixture({
sourceId: 'openstreetmap:n:2',
name: 'beta Bar',
type: 'BAR',
area: 'Solsiden',
}),
venueFixture({
sourceId: 'openstreetmap:n:3',
name: 'Club Gamma',
type: 'NIGHTCLUB',
area: 'Bakklandet',
}),
venueFixture({
sourceId: 'openstreetmap:n:4',
name: 'Alpine Pub',
type: 'PUB',
area: 'Ila',
}),
])
await db.exec(`UPDATE venues
SET rating = CASE name
WHEN 'Alpha Taproom' THEN 4.2
WHEN 'Club Gamma' THEN 4.8
WHEN 'Alpine Pub' THEN 4.8
ELSE NULL
END`)

const server = new ApolloServer({
typeDefs,
resolvers: createResolvers({
query: (sql, values) => db.query<Record<string, unknown>>(sql, values),
}),
})
return { db, server }
}

const catalogQuery = `
query Catalog(
$search: String
$types: [VenueType!]
$sort: VenueSort
$offset: Int
$limit: Int
) {
venues(
search: $search
types: $types
sort: $sort
offset: $offset
limit: $limit
) {
totalCount
items { name rating type }
}
venueTypes
}
`

type CatalogData = {
venues: {
totalCount: number
items: { name: string; rating: number | null; type: string }[]
}
venueTypes: string[]
}

async function executeCatalog(
server: ApolloServer,
variables: Record<string, unknown>,
) {
const result = await server.executeOperation({
query: catalogQuery,
variables,
})
assert.equal(result.body.kind, 'single')
if (result.body.kind !== 'single') throw new Error('Expected single response')
assert.equal(result.body.singleResult.errors, undefined)
return result.body.singleResult.data as CatalogData
}

test('GraphQL searches case-insensitively by name, area and type', async () => {
const { db, server } = await setupCatalog()
try {
for (const [search, expected] of [
[' alpha ', ['Alpha Taproom']],
['SOLSIDEN', ['beta Bar']],
['pub', ['Alpha Taproom', 'Alpine Pub']],
['%', []],
] as const) {
const data = await executeCatalog(server, { search })
assert.equal(data.venues.totalCount, expected.length)
assert.deepEqual(
data.venues.items.map(({ name }) => name),
expected,
)
}

const blank = await executeCatalog(server, { search: ' ' })
assert.equal(blank.venues.totalCount, 4)
} finally {
await server.stop()
await db.close()
}
})

test('GraphQL combines venue types with OR and counts the complete filtered result', async () => {
const { db, server } = await setupCatalog()
try {
const data = await executeCatalog(server, {
types: ['PUB', 'NIGHTCLUB'],
offset: 1,
limit: 1,
})
assert.equal(data.venues.totalCount, 3)
assert.deepEqual(
data.venues.items.map(({ name }) => name),
['Alpine Pub'],
)

const noFilter = await executeCatalog(server, { types: [] })
assert.equal(noFilter.venues.totalCount, 4)
assert.deepEqual(data.venueTypes, ['BAR', 'NIGHTCLUB', 'PUB'])
} finally {
await server.stop()
await db.close()
}
})

test('GraphQL sorts ratings highest first, nulls last and uses name as a stable tie-breaker', async () => {
const { db, server } = await setupCatalog()
try {
const data = await executeCatalog(server, { sort: 'RATING' })
assert.deepEqual(
data.venues.items.map(({ name }) => name),
['Alpine Pub', 'Club Gamma', 'Alpha Taproom', 'beta Bar'],
)
} finally {
await server.stop()
await db.close()
}
})