3. Mongoose
3. Mongoose
Tijdens deze les bouwen we een API waarmee een film database beheert kunnen worden. In tegenstelling tot vorige les gebruiken we een document database in de plaats van een relationele database en Mongoose in de plaats van Prisma.
Startbestanden
Document Databases
Zoals reeds behandeld in het opleidingsonderdeel database advanced (of datamodellering in het oude curriculum), vertonen document databases enkele belangrijke verschillen ten opzichte van relationele databases.
Document databases bieden niet noodzakelijk ondersteuning voor joins, daarnaast is het schema veel flexibeler dan dat van een relationele database. Document databases zijn ontwikkeld met een gedistribueerd model in het achterhoofd, het is veel eenvoudiger om de databases te verdelen over verschillende machines dan voor relationele databases. Alhoewel de vorige punten al positief zijn, licht de grootste kracht van een document database in het feit dat een object in een objectgeoriënteerde programmeertaal rechtstreeks gemapt wordt naar een document in de database.
Omwille van de één-op-één mapping tussen documenten en objecten, wordt er veel meer data gedupliceerd en minder genormaliseerd dan in een relationele database. Dit gaat gepaard met het feit dat het schema veel flexibeler is, de programmeur kan de structuur van de data snel en eenvoudig aanpassen. Het is ook veel eenvoudiger om te beginnen met programmeren, een goede relationele database vereist dat er, op voorhand, goed nagedacht wordt over de structuur van de database. Omdat de structuur van een document database losser is, kan je als programmeur eenvoudig beginnen te ontwikkelen.
Document databases zijn vooral interessant voor grote hoeveelheden ongestructureerde data. Denk aan de producten op een e-commerce platform, verschillende soorten objecten hebben heel verschillende attributen. Hoe je dit ook modelleert, het gaat altijd inëfficient zijn omdat er zeer veel NULL-waardes bewaard moeten worden. Alhoewel NULL-waardes niet noodzakelijk ruimte innemen in de database, is het, afhankelijk van de configuratie van de tabellen, mogelijk dat een NULL-waarde wel ruimte inneemt op de harde schijf. Dus is het voor ongestructureerde data dikwijls ook mogelijk om de vereiste opslag aanzienlijk te verminderen.
MongoDB
Tijdens deze les gebruiken we MongoDB, een populaire document database die veel features bevat die standaard niet aanwezig zijn op een document database, bijvoorbeeld het afdwingen van een schema en lookup operatie die ongeveer overeenkomt met joins.
Net zoals vorige les gebruiken we opnieuw een Docker file om MongoDB te installeren. MongoDB biedt een gratis online versie aan, genaamd MongoDB Atlas, in de appendix wordt besproken hoe je hiermee verbinding kan maken.
MongoDB Compass
MongoDB Compass is een grafische interface waarmee de MongoDB instantie geconfigureerd kan worden. We gebruiken deze tool om enkele documenten in een nieuwe collectie te plaatsen.
Als Compass opstart moet connectie gemaakt worden met de database, hiervoor gebruiken we onderstaande connectionstring, het is natuurlijk wel nodig dat de docker container gestart is.
mongodb://root:mongoPassword@127.0.0.1:27017/?authMechanism=DEFAULT
Nadat we op "Save & Connect" drukken en verbinding gemaakt hebben met de database kan een nieuwe collectie aangemaakt worden in de local database.

Tenslotte importeren we enkele films vanuit local.movies.json (te vinden in de startbestanden).

Model & Schema
Om te communiceren met MongoDB gebruiken we Mongoose, deze ORM-tool kan gebruikt worden om modellen te definiëren, data te valideren, queries te schrijven, ... Mongoose kan geïnstalleerd worden met onderstaand commando.
Om met Mongoose en TypeScript te werken moeten we eerste een interface definiëren die alle properties beschrijft, de IMovie interface is reeds beschikbaar in de startbestanden.
Merk op dat bijna alle properties als optioneel gemarkeerd zijn. We doen dit omdat document databases vooral krachtig zijn voor data met veel NULL waarden, de data die we hierboven toegevoegd hebben bevat, vooral in het laatste document, dan ook heel wat NULL waarden (die niet bewaard worden in de database, de property is dan ook niet aanwezig).
interface IPerson {
name: string,
firstName: string
}
export interface IMovie {
id: string
title: string
year?: number
runtime?: number
genre?: string[]
directors?: IPerson[]
writers?: IPerson[]
actors?: IPerson[]
alternateVersions?: string[]
plot?: string
language?: string[]
country?: string
awards?: string
poster?: string
imdbRating?: string
imdbId: string
boxOffice?: string
reviews?: { title: string, user: string, text: string }[]
genres?: string[]
languages?: string[]
actorsString: string
}Nadat de interface aangemaakt is kunnen we deze gebruiken om een model aan te maken voor Mongoose, spijtig genoeg moeten we hier de volledige interface herhalen. Een schema in Mongoose is meer dan enkel de beschrijving van properties, naast de properties kunnen er bijvoorbeeld ook virtuele (berekende) properties toegevoegd worden.
Merk op dat we de collectie aangeven waarmee dit object overeen komt. Daarnaast voegen we ook validatie toe voor enkele properties, voor het releasejaar en de runtime gebruiken we een minimum waarde, voor de poster verwachten we dat de string begint met data:image. Dit geeft aan dat dit een base64 encoded data URL is, een tekstuele voorstelling van een binair bestand. Zie backend les 4 voor meer informatie over data URLs.
Waarschuwing
We bewaren de poster hier via base64 om de voorbeelden eenvoudig te houden, dit is geen goed idee in een echte applicatie omdat dit de database vertraagd. Het is veel beter om een geoptimaliseerde blob storage service te gebruiken, bijvoorbeeld een Amazon S3 bucket of de blob storage die in BaaS tools zoals Firebase, Supabase en Appwrite beschikbaar is.
Voor meer informatie over de validatiemogelijkheden verwijzen we naar de documentatie.
Tenslotte wordt het schema omgevormd tot een model, via dit model kunnen we de CRUD-operaties voor de collectie aanspreken, dit object moet dan ook geëxporteerd worden.
const MovieSchema = new Schema<IMovie>(
{
title: {
type: String,
required: true,
},
year: {
type: Number,
min: [1888, 'The first ever movie was recorded in 1888, the release year must be later than this.'],
},
runtime: {
type: Number,
min: [1, 'The runtime must be at least 1 second'],
},
genre: [String],
directors: [{name: String, firstName: String}],
writers: [{name: String, firstName: String}],
actors: [{name: String, firstName: String}],
alternateVersions: [String],
plot: String,
language: [String],
country: String,
awards: String,
poster: {
type: String,
match: /^data:image.*$/,
},
imdbRating: Number,
imdbId: {
type: String,
required: true,
},
boxOffice: String,
reviews: [{title: String, user: String, rating: String, text: String}],
genres: [String],
languages: [String],
},
{
collection: 'movies',
},
)
export const Movie = model<IMovie>('Movie', MovieSchema)Database verbinding
Voordat we verbinding kunnen maken met de database moet de connectionstring genoteerd worden in .env, hiervoor gebruiken we onderstaande string.
DATABASE_URL="mongodb://root:mongoPassword@127.0.0.1:27017/local?authMechanism=DEFAULT&authSource=admin"In de vorige les werd de connectionstring automatisch ingelezen door Prisma, dit keer is dit niet mogelijk. Vanaf Node 20.6.0, de versie eind oktober als LTS aangeduid is, kan Node met .env files overweg. Voordat deze versie moest dotenv gebruikt worden.
Waarschuwing
Als je, eerder dit jaar, het vak "Frontend Frameworks" gevolgd hebt, heb je versie 18.12.0 van Node geïnstalleerd. Dit was toen de LTS-versie, we raden iedereen aan om te updaten naar deze LTS. Doe je dit niet, dan moet je zelf uitzoeken hoe dotenv werkt.
Om de environment file in te laden moet het dev commando in package.json aangepast worden.
{
"name": "mobile_lecture3_example",
"version": "1.0.0",
"description": "",
"main": "app.js",
"scripts": {
"dev": "tsx --watch --env-file=.env ./src/app.ts",
"build": "tsc",
"lint": "eslint ./src/ --fix",
"migrate": "prisma migrate dev"
},
...
}Om de verbinding met de database op te zetten moeten we connect functie van Mongoose gebruiken. In tegenstelling tot de vorige les, hebben we de client niet rechtstreeks nodig om een CRUD-operatie uit te voeren, daarom initialiseren we de verbinding in app.ts. Vervolgens maakt het Movie model gebruik van deze verbinding.
import mongoose from 'mongoose'
import {env} from 'process'
const databaseUrl = env.DATABASE_URL
if (!databaseUrl) {
throw new Error('No DATABASE URL specified.')
}
const _ = await mongoose.connect(databaseUrl)
// Middleware, routes en starten van de server
// weggelaten uit dit fragment.Films uitlezen
De route om films uit te lezen is reeds beschikbaar in de startbestanden, de DAL-methode moet nog uitgewerkt worden. Via de find methode kunnen alle documenten in een model uitgelezen worden.

Film aanmaken
Een film kan aangemaakt worden via de create methode. Merk op dat we hieronder een try-catch gebruiken rond de oproep naar de DAL-laag. Als Mongoose detecteert dat één van de waarden niet voldoet aan de voorwaarden die gedefinieerd zijn in het schema, wordt er een error opgegooid.
Deze error bevat de foutmelding die in het schema opgegeven is.
movies.post('/movies', async (req: Request<void, IMovie, IdOptional<IMovie>>, res: Response) => {
try {
const newMovie = await createMovie(req.body)
res.statusCode = 201
res.send(newMovie)
} catch (e) {
res.statusCode = 400
res.send(e.message)
}
})export const createMovie = async (movie: IdOptional<IMovie>): Promise<IMovie> => {
return Movie.create({...movie})
}Alhoewel bovenstaande code werkt, is het interessant om de try-catch af te zonderen in een extra functie, het is tenslotte mogelijk dat deze structuur verschillende keren gebruikt moet worden. Zowel voor een update als create operatie kan deze structuur gebruikt worden, ook voor alle andere modellen in de API kan de structuur gebruikt worden.
De nieuwe methode is generisch[1] en neemt naast het response object een functie als argument die een Promise teruggeeft van het type T.
import {Response} from 'express'
export async function catchMongooseValidationError<T>(res: Response, dalFn: () => Promise<T>): Promise<void> {
try {
const newData = await dalFn()
res.statusCode = 201
res.send(newData)
} catch (e) {
res.statusCode = 400
res.send((e as Error).message)
}
}movies.post('/movies', async (req: Request<void, IMovie, IdOptional<IMovie>>, res: Response) => {
await catchMongooseValidationError<IMovie>(res, () => createMovie(req.body))
})Specifieke film selecteren
Om een specifieke film te selecteren kunnen we de findById methode gebruiken. Ook hier is het interessant om de controle op een null waarde (geen resultaat) af te zonderen in een herbruikbare functie.
import {Response} from 'express'
export async function returnBadRequestWhenNull<T>(res: Response, dalFn: () => Promise<T | null>): Promise<void> {
try {
const data = await dalFn()
res.statusCode = 200
res.send(data)
} catch (e) {
res.statusCode = 400
res.send('No data found matching the specified search parameters.')
}
}interface RouteParams {
movieId: string
}
movie.get('/:movieId', async (req: Request<RouteParams>, res: Response) => {
await returnBadRequestWhenNull(res, () => getMovieById(req.params.movieId))
})export const getMovieById = async (id: string): Promise<IMovie | null> => {
return Movie.findById(id)
}
Film verwijderen
Om een film te verwijderen kunnen we de findByIdAndDelete methode gebruiken. Merk op dat we hier geen controle uitvoeren op foutieve data of lege zoekresultaten, als het ID niet bestaat, wordt er niets verwijderd en wordt er geen error opgegooid.
interface RouteParams {
movieId: string
}
movie.delete('/:movieId', async (req: Request<RouteParams>, res: Response) => {
await deleteMovie(req.params.movieId)
res.sendStatus(200)
})export const deleteMovie = async (id: string) => {
await Movie.findByIdAndDelete(id)
}Film updaten
Een film updaten kan via de findByIdAndUpdate methode. Het is echter wel nodig om de statuscode van de catchMongooseValidationError methode aan te passen zodat we de 200 code kunnen teruggeven in de plaats van de 201 (created) code die we teruggegeven hebben in film aanmaken.
We gebruiken twee configuratieopties voor de findByIdAndUpdate functie, de eerste parameter new wordt gebruikt om aan te geven dat de methode de geüpdatet data moet teruggeven in de plaats van de oude versie. Vie de runValidators property geven we aan dat de validatieregels die in het schema gedefinieerd zijn uitgevoerd moeten worden.
interface RouteParams {
movieId: string
}
movie.put('/:movieId', async (req: Request<RouteParams, IMovie, IdOptional<IMovie>>, res: Response) => {
await catchMongooseValidationError(res, () => updateMovie(req.params.movieId, req.body), 200)
})export const updateMovie = async (id: string, updatedMovie: Partial<IMovie>): Promise<IMovie | null> => {
return Movie.findByIdAndUpdate(
id,
{$set: updatedMovie},
{
new: true,
runValidators: true,
},
)
}import {Response} from 'express'
export async function catchMongooseValidationError<T>(res: Response, dalFn: () => Promise<T>, successCode: number = 201): Promise<void> {
try {
const newData = await dalFn()
res.statusCode = successCode
res.send(newData)
} catch (e) {
res.statusCode = 400
res.send((e as Error).message)
}
}Resultaten filteren
Hieronder bespreken we enkele manieren om de data van de GET /movies route te filteren.
Filteren op title
Het zou bijzonder handig zijn als de lijst van films gefilterd kan worden op basis van naam, via de parameter van de find functie kunnen we een reeks filters meegeven. We geven de filters niet rechtstreeks mee aan de find functie omdat we filters enkel toevoegen als de overeenkomstige parameter niet undefined is.
interface GetAllMoviesParams {
title?: string
}
export const getAllMovies = async ({title}: GetAllMoviesParams): Promise<IMovie[]> => {
const filter: FilterQuery<IMovie> = {
}
if (title) {
filter.title = title
}
return Movie.find(filter)
}interface QueryParams {
title?: string
}
movies.get('/movies', async (req: Request<void, IMovie[], void, QueryParams>, res: Response) => {
const movies = await getAllMovies(req.query)
res.send(movies)
})
Alhoewel bovenstaande filter werkt, is het niet mogelijk om op substrings te zoeken, daarom passen we de filter nog aan naar reguliere expressie die dit mogelijk maakt. Hier worden volgende symbolen gebruikt
^: Het begin van de string$: Het einde van de string.*: Een wildcard match, komt overeen met % in SQL.
De i flag geeft aan dat de match case-insensitive moet zijn.
interface GetAllMoviesParams {
title?: string
}
export const getAllMovies = async ({title}: GetAllMoviesParams): Promise<IMovie[]> => {
const filter: FilterQuery<IMovie> = {
}
if (title) {
filter.title = new RegExp(`^.*${title ?? ''}.*$`, 'i')
}
return Movie.find(filter)
}
Filteren op genres
Als we aan een array-property zoals genres een array meegeven in de filter, kunnen we garanderen dat de genres moeten voldoen aan alle opgegeven genres. Query parameters zijn altijd string, daarom gebruiken we een comma-seperated string voor de genre filter.
interface GetAllMoviesParams {
title?: string
genres?: string
}
export const getAllMovies = async ({title, genres}: GetAllMoviesParams): Promise<IMovie[]> => {
const filter: FilterQuery<IMovie> = {
}
if (title) {
filter.title = new RegExp(`^.*${title ?? ''}.*$`, 'i')
}
if (genres) {
filter.genres = genres.split(',').map(x => x.trim())
}
return Movie.find(filter)
}interface QueryParams {
title?: string
genres?: string
}
movies.get('/movies', async (req: Request<void, IMovie[], void, QueryParams>, res: Response) => {
const movies = await getAllMovies(req.query)
res.send(movies)
})Alhoewel dit werkt, is het niet ideaal dat alle genres in de gekozen film moeten voorkomen, om dit op te lossen kunnen we de $in operator gebruiken.
interface GetAllMoviesParams {
title?: string
genres?: string
}
export const getAllMovies = async ({title, genres}: GetAllMoviesParams): Promise<IMovie[]> => {
const filter: FilterQuery<IMovie> = {
}
if (title) {
filter.title = new RegExp(`^.*${title ?? ''}.*$`, 'i')
}
if (genres) {
filter.genres = {$in: genres.split(',').map(x => x.trim())}
}
return Movie.find(filter)
}
Filteren op acteurs
Om te filteren op acteurs kunnen we niet dezelfde syntax gebruiken als voor de genres, de genres werden bewaard als een string-array terwijl de acteurs bewaard worden als een array van objecten.
interface GetAllMoviesParams {
title?: string
genres?: string
actors?: string
}
export const getAllMovies = async ({title, genres, actors}: GetAllMoviesParams): Promise<IMovie[]> => {
const filter: FilterQuery<IMovie> = {
}
if (title) {
filter.title = new RegExp(`^.*${title ?? ''}.*$`, 'i')
}
if (genres) {
filter.genres = {$in: genres.split(',').map(x => x.trim())}
}
if (actors) {
filter.actors = {
$elemMatch: {
name: {
$in: actors.split(',').map(x => x.trim()),
},
},
}
}
return Movie.find(filter)
}interface QueryParams {
title?: string
genres?: string
actors?: string
}
movies.get('/movies', async (req: Request<void, IMovie[], void, QueryParams>, res: Response) => {
const movies = await getAllMovies(req.query)
res.send(movies)
})
Eén vs alle filters
Als we momenteel verschillende filters met elkaar combineren moeten de films voldoen aan alle filters, het kan echter ook interessant zijn om alle films op te halen die aan één van de filters voldoen.
Momenteel krijgt de find methode één filter object als parameter, om een disjunctieve filter te bouwen hebben we een array van filter objecten nodig. We schrijven een functie om de conversie uit te voeren.
import {FilterQuery} from 'mongoose'
export function transformAndFilterToOrFilter<T>(oldFilters: FilterQuery<T>): FilterQuery<T> {
const filterQueries: FilterQuery<T>[] = []
// Old data has the format
// { propertyName: someFilter, propertyName2: someOtherFilter, ...}
// This must be transformed to
// [{ propertyName: someFilter}, {propertyName2: someOtherFilter}, ... ]
// Key corresponds to the property names.
for (const key of Object.keys(oldFilters)) {
const newFilter: Record<typeof key, FilterQuery<T>> = {}
newFilter[key] = structuredClone(oldFilters[key])
filterQueries.push(newFilter)
}
return {
$or: filterQueries,
}
}interface GetAllMoviesParams {
title?: string
genres?: string
actors?: string
type: 'or' | 'and'
}
export const getAllMovies = async ({title, genres, actors, type}: GetAllMoviesParams): Promise<IMovie[]> => {
const filter: FilterQuery<IMovie> = {}
if (title) {
filter.title = new RegExp(`^.*${title ?? ''}.*$`, 'i')
}
if (genres) {
filter.genres = {$in: genres.split(',').map(x => x.trim())}
}
if (actors) {
filter.actors = {
$elemMatch: {
name: {
$in: actors.split(',').map(x => x.trim()),
},
},
}
}
return Movie.find(type === 'or' ? transformAndFilterToOrFilter(filter) : filter)
}interface QueryParams {
title?: string
genres?: string
actors?: string
type: 'or' | 'and'
}
movies.get('/movies', async (req: Request<void, IMovie[], void, QueryParams>, res: Response) => {
const movies = await getAllMovies(req.query)
res.send(movies)
})Voorbeeldcode
Uitgewerkt lesvoorbeeld met commentaar
Appendix: MongoDB Atlas
MongoDB Atlas is een in de cloud-gehoste MongoDB instantie. Natuurlijk is de gratis versie trager dan een Docker container en is de hoeveelheid opslag gelimiteerd. Voor prototypes of schoolprojecten is dit echter een nuttige service, hieronder beschrijven we hoe je een MongoDB Atlas instantie kan aanmaken en hiermee verbinding mee kan maken vanuit een Node applicatie.
Eens je via bovenstaande link een account aangemaakt hebt, kan je een cloud provider en hosting locatie kiezen.

Selecteer een cloud hoster en een hosting locatie (in Europa).

Nadat het datacenter gekozen is, moet een root user aangemaakt worden.

Vervolgens moeten de IP-adressen opgelijst worden die toegang hebben tot de server, omdat we deze database niet gebruiken in een productieomgeving, gebruiken we het adres 0.0.0.0/0. Zo kunnen we database aanspreken vanop elk toestel.

Tenslotte moet navigeren we naar het dashboard en selecteren daar de connect knop.

In het scherm dat vervolgens verschijnt, kunnen we via Driver > Node.js (5.5 or later). Vervolgens verschijnt er een connectionstring die in de .env file gezet kan worden.

Generische methodes zijn methodes die een parameter nemen waarvan het type niet gekend is, de methode aanvaard dus een parameter van alle mogelijke types. Het is optioneel mogelijk om het type van de generische parameter te beperken tot de types die een bepaalde interface implementeren. Voor meer informatie verwijzen we door naar Wikipedia. ↩︎