Siirry sisältöön

c

Käyttäjien hallinta

Haluamme toteuttaa sovellukseemme käyttäjien hallinnan. Käyttäjät tulee tallettaa tietokantaan, ja jokaisesta muistiinpanosta tulee tietää sen luonut käyttäjä. Muistiinpanojen poiston ja editoinnin tulee olla sallittua ainoastaan muistiinpanot tehneelle käyttäjälle.

Aloitetaan lisäämällä tietokantaan tieto käyttäjistä. Käyttäjän User ja muistiinpanojen Note välillä on yhden suhde moneen ‑yhteys:

Yhteen käyttäjään liittyy monta muistiinpanoa eli UML:nä User 1 --- * Note

Relaatiotietokantoja käytettäessä ratkaisua ei tarvitsisi juuri miettiä. Molemmille olisi oma taulunsa, ja muistiinpanoihin liitettäisiin sen luonutta käyttäjää vastaava id vierasavaimeksi (foreign key).

Dokumenttitietokantoja käytettäessä tilanne on kuitenkin toinen ja erilaisia tapoja mallintaa tilanne on useita.

Olemassaoleva ratkaisumme tallentaa jokaisen luodun muistiinpanon tietokantaan notes-kokoelmaan eli collectioniin. Jos emme halua muuttaa tätä, lienee luontevinta tallettaa käyttäjät omaan kokoelmaansa, nimeltään vaikkapa users.

Mongossa voidaan kaikkien dokumenttitietokantojen tapaan käyttää olioiden id:itä viittaamaan muissa kokoelmissa talletettaviin dokumentteihin, vastaavasti kuten viiteavaimia käytetään relaatiotietokannoissa.

Dokumenttitietokannat kuten Mongo eivät kuitenkaan tue relaatiotietokantojen liitoskyselyitä vastaavaa toiminnallisuutta, joka mahdollistaisi useaan kokoelmaan kohdistuvan tietokantahaun. Tämä ei tarkalleen ottaen enää pidä paikkaansa, sillä versiosta 3.2 alkaen Mongo on tukenut useampaan kokoelmaan kohdistuvia lookup-aggregaattikyselyitä. Emme kuitenkaan käsittele niitä kurssilla.

Jos tarvitsemme liitoskyselyitä vastaavaa toiminnallisuutta, tulee se toteuttaa sovelluksen tasolla eli käytännössä tekemällä tietokantaan useita kyselyitä. Tietyissä tilanteissa Mongoose-kirjasto osaa hoitaa liitosten tekemisen, jolloin kysely näyttää Mongoosen käyttäjälle toimivan liitoskyselyn tapaan. Mongoose tekee kuitenkin näissä tapauksissa taustalla useamman kyselyn tietokantaan.

Viitteet kokoelmien välillä

Jos käyttäisimme relaatiotietokantaa, muistiinpano sisältäisi viiteavaimen sen tehneeseen käyttäjään. Dokumenttitietokannassa voidaan toimia samoin.

Oletetaan, että kokoelmassa users on kaksi käyttäjää:

[
  {
    username: 'mluukkai',
    _id: 123456,
  },
  {
    username: 'hellas',
    _id: 141414,
  },
]

Kokoelmassa notes on kolme muistiinpanoa, joiden kaikkien kenttä user viittaa users-kokoelmassa olevaan käyttäjään:

[
  {
    content: 'HTML is easy',
    important: false,
    _id: 221212,
    user: 123456,
  },
  {
    content: 'The most important operations of HTTP protocol are GET and POST',
    important: true,
    _id: 221255,
    user: 123456,
  },
  {
    content: 'A proper dinosaur codes with Java',
    important: false,
    _id: 221244,
    user: 141414,
  },
]

Mikään ei kuitenkaan määrää dokumenttitietokannoissa, että viitteet on talletettava muistiinpanoihin, vaan ne voivat olla myös (tai ainoastaan) käyttäjien yhteydessä:

[
  {
    username: 'mluukkai',
    _id: 123456,
    notes: [221212, 221255],
  },
  {
    username: 'hellas',
    _id: 141414,
    notes: [221244],
  },
]

Koska käyttäjiin liittyy potentiaalisesti useita muistiinpanoja, niiden id:t talletetaan käyttäjän kentässä notes olevaan taulukkoon.

Dokumenttitietokannat tarjoavat myös radikaalisti erilaisen tavan datan organisointiin; joissain tilanteissa saattaisi olla mielekästä tallettaa muistiinpanot kokonaisuudessa käyttäjien sisälle:

[
  {
    username: 'mluukkai',
    _id: 123456,
    notes: [
      {
        content: 'HTML is easy',
        important: false,
      },
      {
        content: 'The most important operations of HTTP protocol are GET and POST',
        important: true,
      },
    ],
  },
  {
    username: 'hellas',
    _id: 141414,
    notes: [
      {
        content:
          'A proper dinosaur codes with Java',
        important: false,
      },
    ],
  },
]

Muistiinpanot olisivat tässä skeemaratkaisussa siis yhteen käyttäjään alisteisia kenttiä, eikä niillä olisi edes omaa identiteettiä eli id:tä tietokannan tasolla.

Dokumenttitietokantojen yhteydessä skeeman rakenne ei siis ole ollenkaan samalla tavalla ilmeinen kuin relaatiotietokannoissa, ja valittava ratkaisu kannattaa määritellä siten, että se tukee parhaalla tavalla sovelluksen käyttötapauksia. Tämä ei luonnollisestikaan ole helppoa, sillä järjestelmän kaikki käyttötapaukset eivät yleensä ole selvillä kun projektin alkuvaiheissa mietitään datan organisointitapaa.

Hieman paradoksaalisesti tietokannan tasolla skeematon Mongo edellyttääkin projektin alkuvaiheissa jopa radikaalimpien datan organisoimiseen liittyvien ratkaisujen tekemistä kuin tietokannan tasolla skeemalliset relaatiotietokannat, jotka tarjoavat keskimäärin kaikkiin tilanteisiin melko hyvin sopivan tavan organisoida dataa.

Käyttäjien Mongoose-skeema

Päätetään tallettaa käyttäjän yhteyteen myös tieto käyttäjän luomista muistiinpanoista eli käytännössä muistiinpanojen id:t. Määritellään käyttäjää edustava model tiedostoon models/user.js:

const mongoose = require('mongoose')

const userSchema = mongoose.Schema({
  username: String,
  name: String,
  passwordHash: String,
  notes: [
    {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'Note'
    }
  ],
})

userSchema.set('toJSON', {
  transform: (document, returnedObject) => {
    returnedObject.id = returnedObject._id.toString()
    delete returnedObject._id
    delete returnedObject.__v
    // the passwordHash should not be revealed
    delete returnedObject.passwordHash
  }
})

const User = mongoose.model('User', userSchema)

module.exports = User

Muistiinpanojen id:t on talletettu käyttäjien sisälle taulukkona Mongo-id:itä. Määrittely on seuraava:

{
  type: mongoose.Schema.Types.ObjectId,
  ref: 'Note'
}

Kentän tyyppi on ObjectId, joka viittaa note-tyyppisiin dokumentteihin. Mongo ei itsessään tiedä mitään siitä, että kyse on kentästä, joka viittaa nimenomaan muistiinpanoihin, vaan kyseessä on puhtaasti Mongoosen syntaksi.

Laajennetaan tiedostossa model/note.js olevaa muistiinpanon skeemaa siten, että myös muistiinpanossa on tieto sen luoneesta käyttäjästä:

const noteSchema = new mongoose.Schema({
  content: {
    type: String,
    required: true,
    minlength: 5
  },

  important: Boolean,
  user: {    type: mongoose.Schema.Types.ObjectId,    ref: 'User'  }})

Relaatiotietokantojen käytänteistä poiketen viitteet on nyt talletettu molempiin dokumentteihin. Muistiinpano viittaa sen luoneeseen käyttäjään ja käyttäjä sisältää taulukollisen viitteitä sen luomiin muistiinpanoihin.

Käyttäjien luominen

Toteutetaan seuraavaksi route käyttäjien luomista varten. Käyttäjällä on siis username (jonka täytyy olla järjestelmässä yksikäsitteinen), nimi eli name sekä passwordHash eli salasanasta yksisuuntaisen funktion perusteella laskettu tunniste. Salasanojahan ei ole koskaan viisasta tallentaa tietokantaan selväsanaisena!

Asennetaan salasanojen hashaamiseen käyttämämme bcrypt-kirjasto:

npm install bcrypt

Käyttäjien luominen tapahtuu osassa 3 läpikäytyjä RESTful-periaatteita seuraten tekemällä HTTP POST ‑pyyntö polkuun users.

Määritellään käyttäjienhallintaa varten oma router tiedostoon controllers/users.js, ja liitetään se app.js-tiedostossa huolehtimaan polulle /api/users/ tulevista pyynnöistä:

const usersRouter = require('./controllers/users')

// ...

app.use('/api/users', usersRouter)

Routerin alustava sisältö on seuraava:

const bcrypt = require('bcrypt')
const usersRouter = require('express').Router()
const User = require('../models/user')

usersRouter.post('/', async (request, response) => {
  const { username, name, password } = request.body

  const saltRounds = 10
  const passwordHash = await bcrypt.hash(password, saltRounds)

  const user = new User({
    username,
    name,
    passwordHash,
  })

  const savedUser = await user.save()

  response.status(201).json(savedUser)
})

module.exports = usersRouter

Tietokantaan siis ei talleteta pyynnön mukana tulevaa salasanaa, vaan funktion bcrypt.hash avulla laskettu hash.

Materiaalin tilamäärä ei valitettavasti riitä käsittelemään sen tarkemmin salasanojen tallennuksen perusteita, esim. mitä maaginen luku 10 muuttujan saltRounds arvona tarkoittaa. Lue linkkien takaa lisää.

Koodissa ei tällä hetkellä ole mitään virheidenkäsittelyä eikä validointeja eli esim. käyttäjätunnuksen ja salasanan muodon tarkastuksia.

Uutta ominaisuutta voi ja kannattaakin joskus testailla käsin esim. Postmanilla. Käsin tapahtuva testailu muuttuu kuitenkin nopeasti työlääksi, etenkin kun tulemme pian vaatimaan, että samaa käyttäjätunnusta ei saa tallettaa kantaan kahteen kertaan.

Pienellä vaivalla voimme tehdä automaattisesti suoritettavat testit, jotka helpottavat sovelluksen kehittämistä merkittävästi.

Alustava testi näyttää seuraavalta:

const bcrypt = require('bcrypt')
const User = require('../models/user')

//...

describe('when there is initially one user at db', () => {
  beforeEach(async () => {
    await User.deleteMany({})

    const passwordHash = await bcrypt.hash('sekret', 10)
    const user = new User({ username: 'root', passwordHash })

    await user.save()
  })

  test('creation succeeds with a fresh username', async () => {
    const usersAtStart = await helper.usersInDb()

    const newUser = {
      username: 'mluukkai',
      name: 'Matti Luukkainen',
      password: 'salainen',
    }

    await api
      .post('/api/users')
      .send(newUser)
      .expect(201)
      .expect('Content-Type', /application\/json/)

    const usersAtEnd = await helper.usersInDb()
    assert.strictEqual(usersAtEnd.length, usersAtStart.length + 1)

    const usernames = usersAtEnd.map(u => u.username)
    assert(usernames.includes(newUser.username))
  })
})

Testit käyttävät myös tiedostossa tests/test_helper.js määriteltyä apufunktiota usersInDb() tarkastamaan lisäysoperaation jälkeisen tietokannan tilan:

const User = require('../models/user')

// ...

const usersInDb = async () => {
  const users = await User.find({})
  return users.map(u => u.toJSON())
}

module.exports = {
  initialNotes,
  nonExistingId,
  notesInDb,
  usersInDb,
}

Lohko beforeEach lisää kantaan käyttäjän, jonka username on root. Voimmekin tehdä uuden testin, jolla varmistetaan, että samalla käyttäjätunnuksella ei voi luoda uutta käyttäjää:

describe('when there is initially one user at db', () => {
  // ...

  test('creation fails with proper statuscode and message if username already taken', async () => {
    const usersAtStart = await helper.usersInDb()

    const newUser = {
      username: 'root',
      name: 'Superuser',
      password: 'salainen',
    }

    const result = await api
      .post('/api/users')
      .send(newUser)
      .expect(400)
      .expect('Content-Type', /application\/json/)

    const usersAtEnd = await helper.usersInDb()
    assert(result.body.error.includes('expected `username` to be unique'))

    assert.strictEqual(usersAtEnd.length, usersAtStart.length)
  })
})

Testi ei tietenkään mene läpi tässä vaiheessa. Toimimme nyt TDD:n eli test driven developmentin hengessä, eli uuden ominaisuuden testi kirjoitetaan ennen ominaisuuden ohjelmointia.

Mongoosen validoinnit eivät tarjoa täysin suoraa tapaa kentän arvon uniikkiuden tarkastamiseen. Uniikkius on kuitenkin mahdollista saada aikaan määrittelemällä tietokannan kokoelmaan kentän arvon yksikäsitteisyyden takaava indeksi. Määrittely tapahtuu seuraavasti:

const userSchema = mongoose.Schema({
  username: {    type: String,    required: true,    unique: true // username oltava yksikäsitteinen  },  name: String,
  passwordHash: String,
  notes: [
    {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'Note'
    }
  ],
})


// ...

Yksikäsitteisyyden takaavan indeksin kanssa on kuitenkin oltava tarkkana. Jos tietokannassa on jo dokumentteja jotka rikkovat yksikäsitteisyysehdon, ei indeksiä muodosteta. Eli lisätessäsi yksikäsitteisyysindeksin, varmista että tietokanta on eheässä tilassa! Yllä oleva testi lisäsi tietokantaan kahteen kertaan käyttäjän käyttäjänimellä root, ja nämä on poistettava jotta indeksi muodostuu ja koodi toimii.

Mongoosen validaatiot eivät huomaa indeksin rikkoutumista, ja niistä seuraa virheen ValidationError sijaan virhe, jonka tyyppi on MongoServerError. Joudummekin laajentamaan virheenkäsittelijää, jotta virhe saadaan asianmukaisesti hoidettua:

const errorHandler = (error, request, response, next) => {
  if (error.name === 'CastError') {
    return response.status(400).send({ error: 'malformatted id' })
  } else if (error.name === 'ValidationError') {
    return response.status(400).json({ error: error.message })
  } else if (error.name === 'MongoServerError' && error.message.includes('E11000 duplicate key error')) {    return response.status(400).json({ error: 'expected `username` to be unique' })  }
  next(error)
}

Näiden muutosten jälkeen testit menevät läpi.

Voisimme toteuttaa käyttäjien luomisen yhteyteen myös muita tarkistuksia, esim. onko käyttäjätunnus tarpeeksi pitkä, koostuuko se sallituista merkeistä ja onko salasana tarpeeksi hyvä. Jätämme ne kuitenkin vapaaehtoiseksi harjoitustehtäväksi.

Ennen kuin menemme eteenpäin, lisätään sovellukseen alustava versio kaikki käyttäjät palauttavasta käsittelijäfunktiosta:

usersRouter.get('/', async (request, response) => {
  const users = await User.find({})
  response.json(users)
})

Lista näyttää seuraavalta:

Selain renderöi osoitteessa localhost:3001/api/users taulukollisen JSON:eja joilla kentät username, name, id ja notes, jonka arvo on tyhjä taulukko

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissä part4-7.

Muistiinpanon luominen

Muistiinpanot luovaa koodia on nyt mukautettava siten, että uusi muistiinpano tulee liitetyksi sen luoneeseen käyttäjään.

Laajennetaan ensin olemassaolevaa toteutusta siten, että tieto muistiinpanon luovan käyttäjän id:stä lähetetään pyynnön rungossa kentän userId arvona:

const User = require('../models/user')

//...

notesRouter.post('/', async (request, response) => {
  const body = request.body

  const user = await User.findById(body.userId)
  const note = new Note({
    content: body.content,
    important: body.important === undefined ? false : body.important,
    user: user._id  })

  const savedNote = await note.save()
  user.notes = user.notes.concat(savedNote._id)  await user.save()
  response.json(savedNote)
})

Huomionarvoista on nyt se, että myös user-olio muuttuu. Sen kenttään notes talletetaan luodun muistiinpanon id:

const user = User.findById(body.userId)

// ...

user.notes = user.notes.concat(savedNote._id)
await user.save()

Kokeillaan nyt lisätä uusi muistiinpano:

Näkymä Postmanista luotaessa muistiinpano jolla on validit kentät

Operaatio vaikuttaa toimivan. Lisätään vielä yksi muistiinpano ja mennään kaikkien käyttäjien sivulle:

Selain renderöi osoitteessa localhost:3001/api/users taulukollisen JSON:eja joilla kentät username, name, id ja notes, jonka arvo on taulukko muistiinpanojen id:itä

Huomaamme siis, että käyttäjällä on kaksi muistiinpanoa.

Muistiinpanon luoneen käyttäjän id tulee näkyviin muistiinpanon yhteyteen:

Selain renderöi osoitteessa localhost:3001/api/notes taulukollisen JSON:eja joilla kentät content, important, id ja user, jonka arvo käyttäjäid

populate

Haluaisimme API:n toimivan siten, että haettaessa esim. käyttäjien tiedot polulle /api/users tehtävällä HTTP GET ‑pyynnöllä tulisi käyttäjien tekemien muistiinpanojen id:iden lisäksi näyttää niiden sisältö. Relaatiotietokannoilla toiminnallisuus toteutettaisiin liitoskyselyn avulla.

Kuten aiemmin mainittiin, eivät dokumenttitietokannat tue (kunnolla) eri kokoelmien välisiä liitoskyselyitä. Mongoose-kirjasto osaa kuitenkin tehdä liitoksen puolestamme. Mongoose toteuttaa liitoksen tekemällä useampia tietokantakyselyitä, joten siinä mielessä kyseessä on täysin erilainen tapa kuin relaatiotietokantojen liitoskyselyt, jotka ovat transaktionaalisia, eli liitoskyselyä tehdessä tietokannan tila ei muutu. Mongoosella tehtävä liitos taas on sellainen, että mikään ei takaa sitä, että liitettävien kokoelmien tila on konsistentti, toisin sanoen jos tehdään users- ja notes-kokoelmat liittävä kysely, kokoelmien tila saattaa muuttua kesken Mongoosen liitosoperaation.

Liitoksen tekeminen suoritetaan Mongoosen komennolla populate. Päivitetään ensin kaikkien käyttäjien tiedot palauttava route:

usersRouter.get('/', async (request, response) => {
  const users = await User    .find({}).populate('notes')
  response.json(users)
})

Funktion populate kutsu siis ketjutetaan kyselyä vastaavan metodikutsun (tässä tapauksessa find) perään. Populaten parametri määrittelee, että user-dokumenttien notes-kentässä olevat note-olioihin viittaavat id:t korvataan niitä vastaavilla dokumenteilla.

Lopputulos on jo melkein haluamamme kaltainen:

Selain renderöi osoitteessa localhost:3001/api/users taulukollisen JSON:eja joilla kentät username, name, id ja notes. Kenttä notes on nyt olio jolla on kentät content, important, id ja user

Populaten yhteydessä on myös mahdollista rajata mitä kenttiä sisällytettävistä dokumenteista otetaan mukaan. Haluamme id:n lisäksi nyt ainoastaan kentät content ja important. Rajaus tapahtuu Mongon syntaksilla:

usersRouter.get('/', async (request, response) => {
  const users = await User
    .find({}).populate('notes', { content: 1, important: 1 })

  response.json(users)
})

Tulos on täsmälleen sellainen kuin haluamme:

Selain renderöi osoitteessa localhost:3001/api/users taulukollisen JSON:eja joilla kentät username, name, id ja notes. Kenttä notes on nyt olio jolla on ainoastaan halutut kentät content, important

Lisätään sopiva käyttäjän tietojen populointi muistiinpanojen yhteyteen:

notesRouter.get('/', async (request, response) => {
  const notes = await Note
    .find({}).populate('user', { username: 1, name: 1 })

  response.json(notes)
})

Nyt käyttäjän tiedot tulevat muistiinpanon kenttään user:

Selain renderöi osoitteessa localhost:3001/api/notes taulukollisen JSON:eja joilla kentät content, important, id ja user. Kenttä user on olio jolla on kentät name, username ja id

Korostetaan vielä, että tietokannan tasolla ei siis ole mitään määrittelyä sille, että esim. muistiinpanojen kenttään user talletetut id:t viittaavat users-kokoelman dokumentteihin.

Mongoosen populate-funktion toiminnallisuus perustuu siihen, että olemme määritelleet viitteiden "tyypit" olioiden Mongoose-skeemaan ref-kentän avulla:

const noteSchema = new mongoose.Schema({
  content: {
    type: String,
    required: true,
    minlength: 5
  },
  important: Boolean,
  user: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User'
  }
})

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissä part4-8.