Moduł 3 - Zajęcia 6 - ODM Mongoose
1.1 Mongoose
Mongoose to specjalna biblioteka ODM (Object Data Modelling) służąca do pracy z MongoDB.
Mongoose służy do utworzenia schematów kolekcji w MongoDB, zapewnia przez to możliwość walidacji danych i trzymania się określonych typów danych. Oficjalną dokumentację można zobaczyć tutaj.
1.2 Podłączenie
Do pracy z Mongoose należy zainstalować bibliotekę (będziemy korzystali z wersji 7.1.0:
npm install mongoose -S
Następnie należy zażądać mongoose:
const mongoose = require('mongoose');
Podłączenie do bazy tworzymy przy pomocy metody mongoose.connect(), w której jako pierwszy parametr przekazuje się adres do podłączenia do bazy danych, a jako drugi obiekt ustawień:
Zmienna uriDb powinna zawierać string SRV którego używaliśmy w Compass i Robo3T, pochodzący z pliku .env
const connection = mongoose.connect(uriDb, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
Przy pomocy metody mongoose.disconnect() możemy odłączyć się od bazy danych.
1.3 Schemat dokumentu
Jedną z korzyści płynących z ODM Mongoose jest, że daną kolekcję można opisać konkretnym schematem. Tworzymy ją w następujący sposób:
const { Schema } = mongoose;
Tworzymy instancję klasy Schema
const dogs = new Schema({
nickname: String,
age: Number,
});
Schemat powinien zawierać metadane obiektów. Definiujemy więc, jakie właściwości będzie mieć każdy dokument w kolekcji i jakie będą ich typy danych. Dla typów danych można wskazać jedną z następujących wartości:
- String
- Number
- Date
- Buffer
- Boolean
- Schema.Types.mixed (dowolne dane)
- Schema.Types.ObjectId
- Array
Dla skomplikowanych właściwości, takich jak obiekt, zamiast pojedynczego typu możemy zdefiniować bardziej skomplikowaną strukturę
const dogs = new Schema({
nickname: String,
age: Number,
owner: {
name: String,
address: [String], // typ - tablica stringów
birthday: Date,
},
});
Przy tworzeniu schematu, Mongoose może wykorzystać wbudowane zasady walidacji, które można dodać do definicji dokumentów:
- required: wymaga obowiązkowej wartości dla właściwości;
- min i max: wprowadza minimalne i maksymalne wartości dla danych liczbowych;
- minlength i maxlength: wprowadza minimalną i maksymalną długość dla typu string;
- enum: definiuje, że wartość powinna reprezentować jedną ze wskazanych w tablicy;
- match: string powinien odpowiadać regularnemu wyrażeniu.
const dogs = new Schema({
nickname: {
type: String,
minlength: 2,
maxlength: 7,
required: [true, 'Nickname is required'],
// druga wartość to komunikat który pokaże się
// jeśli wartość nie spełnia warunku
},
age: {
type: Number,
min: 1,
max: 50,
},
owner: {
name: String,
address: [String], // typ - tablica stringów
birthday: Date,
},
});
Jeżeli spróbujesz dodać nieprawidłowe dane do bazy danych wykorzystując Schema, to zapytanie zwróci błąd.
Następnie należy utworzyć model, wykorzystując składnię:
const Dog = mongoose.model('dog', dogs);
Pierwszy parametr w metodzie mongoose.model wskazuje na nazwę modelu, a drugi parametr to schemat danych, opcjonalny trzeci parametr będzie nazwą kolekcji, jeśli go nie podamy, Mongoose stworzy ją na podstawie nazwy modelu.
Dalej można tworzyć obiekty jako instancje modelu:
const dog = new Dog({
nickname: 'Burek',
age: 1,
});
W celu zapisania obiektu w bazie wywołuje się metodę save. Dostępna jest dla wszystkich tworzonych instancji modelu i zapisuje dany obiekt do bazy danych.
Metoda zwraca rezultat, obiekt typu Document, który reprezentuje konkretny dokument zapisany w kolekcji.
const result = dog.save();
console.log('Pies zapisany do bazy! ', result); Pies zapisany w bazie
1.4 Indeksy
Jeśli któreś pole często jest wykorzystywane przy wyszukiwaniu dokumentów, można mu nadać indeks. Indeksowanie pól pozwala szybciej wykonywać wyszukiwanie po tych polach. Indeks można dodać do pola na dwa sposoby.
Pierwszy to określić go w samym schemacie:
const dogs = new Schema({
nickname: { type: String, index: 1 },
age: Number,
});
Lub wywołując metodę index na instancji Schema:
const dogs = new Schema({
nickname: String,
age: Number,
});
dogs.index({ nickname: 1 });
1.5 Unikalne pola
Wartość pola można uczynić unikalną na poziomie Mongoose. Oznacza to, że może istnieć tylko jeden dokument o danej wartości konkretnego pola.
Na przykład sensowne jest oznaczenie pola email jako unikalnego w kolekcji users. W tym celu, w schemacie, przy określaniu pola, należy dodać właściwość unique:
const user = new Schema({
username: String,
email: { type: String, unique: true },
});
1.6 Pola obowiązkowe
Jeśli przy utworzeniu nowego dokumentu nie wskażemy pola, to obiekt będzie utworzony bez tego pola. Niektóre pola powinny obowiązkowo brać udział w nowo utworzonym obiekcie Document. Na przykład, także nazwa użytkownika i email w dokumencie powinny być obowiązkowe, możliwe że będziemy musieli wysyłać maile do użytkownika w celu ponownego utworzenia hasła i tak dalej. Za pole obowiązkowe odpowiada właściwość required.
const user = new Schema({
username: { type: String, required: true },
email: { type: String, unique: true, required: true },
});
UWAGA: wszelkie definicje, w tym unikalność pola, istnieją tylko podczas operowania na bazie danych przy użyciu Mongoose z danymi schematami, jeśli będziemy edytować lub dodawać dokumenty np. przez Robo3T, to te zasady nie zostaną zastosowane!
1.7 Metody w obiekcie schema
W schematach, które określa moduł Mongoose istnieje możliwość dodania metod dla instancji modelu. Daje to możliwość wywołania wcześniej określonych metod, wykorzystując obiekt typu Document.
W celu dodania metody do obiektu typu Schema trzeba dodać funkcję do właściwości Schema.methods. Wewnątrz takiej funkcji dostęp do obiektu schematu zachodzi po słowie kluczowym this.
Typowy przykład wykorzystania to utworzenie funkcji, która zwraca pełną nazwę użytkownika, szyfruje hasło użytkownika i tak dalej.
const user = new Schema({
firstName: String,
lastName: String,
});
user.methods.fullName = function () {
return `${this.firstName} ${this.lastName}`;
};
Wyszukując później dokument z bazy, możemy wywoływać na nim funkcję fullName() zwracającą pełne imię i nazwisko użytkownika.
1.8 Podstawowe operacje z danymi w Mongoose.
1.9 Tworzenie dokumentów
Oprócz przeanalizowanej metody save(), można również wykorzystywać metodę modelu Dog.create(). Jako pierwszy parametr metody przekazuje się obiekt do zapisania.
Cat.create({
nickname: 'Barsik',
age: 1,
});
1.10 Otrzymanie danych
Aby wyszukać wiele dokumentów można wykorzystać na modelu metodę (podobnie jak w Robo3T).
Model.find([query], [project]);
Lub dla jednego dokumentu:
Model.findOne([query], [project]);
1.11 Usunięcie danych
Do usunięcia danych stosuje się następujące metody:
deleteOne([query], [options]);
deleteMany([query], [options]);
Tworzy operację usunięcia, w toku której z kolekcji usuwane są wszystkie lub jeden dokument zgodny z query.
findOneAndDelete([query], [options]);
Tworzy operację wyszukania i usunięcia jednego dokumenty (pierwszego pasującego do query)
1.12 Modyfikowanie dokumentu
updateOne([query], [update], [options]);
updateMany([query], [update], [options]);
Te metody aktualizują odpowiednio jeden lub wiele dokumentów
findOneAndUpdate([query], [update], [options]);
Aktualizuje i zwraca pierwszy pasujący dokument.
UWAGA: w Mongoose niektóre opcje nazywają się inaczej niż gdy korzystamy bezpośrednio z MongoSH
2.1 Podłączenie Mongoose w projekcie
Przeanalizujmy teraz aplikację REST API i podłączenie do niej Mongoose. Struktura naszej aplikacji będzie następująca:
Z całą aplikacją możecie zaznajomić się tutaj:
API dostępne pod tym URL: https://nodebook-api-mongoose.glitch.me/api/tasks. Znów możesz przy pomocy Postman wykonać wszystkie operacje CRUD.
API jest dostępne pod tym URL: https://nodebook-api-mongoose.glitch.me/api/tasks.
Możesz przy pomocy Postman wykonać wszystkie operacje CRUD.
Funkcje do pracy z bazą danych i schematy oddzielamy od reszty aplikacji i umieszczamy w folderze service.
Określamy schematy dla dokumentów w sub-folderze schemas.
Routes zostaną w głównym katalogu aplikacji, w folderze api, a logikę pracy aplikacji (obsługę endpointów) przenosimy do folderu controller.
Przeanalizujmy teraz bardziej szczegółowo każdy element naszej aplikacji.
2.2 Plik główny i podłączenie do bazy danych
Plik serwera server.js. Podłączamy niezbędne moduły i tworzymy instancję aplikacji express.
const express = require('express');
const cors = require('cors');
const mongoose = require('mongoose');
require('dotenv').config();
const app = express();
Podłączamy parser JSON i pozwalamy na międzydomenowe zapytania do naszej aplikacji poprzez middleware cors:
// parse application/json
app.use(express.json());
// cors
app.use(cors());
Podłączamy routes dla naszego API, a także obsługę błędów 404 i błędów serwera 500:
const routerApi = require('./api');
app.use('/api', routerApi);
app.use((_, res, __) => {
res.status(404).json({
status: 'error',
code: 404,
message: 'Use api on routes: /api/tasks',
data: 'Not found',
});
});
app.use((err, _, res, __) => {
console.log(err.stack);
res.status(500).json({
status: 'fail',
code: 500,
message: err.message,
data: 'Internal Server Error',
});
});
Podłączamy się do serwera MongoDB przy pomocy mongoose.connect. Ta metoda zwraca promise, po którego poprawnym zakończeniu, uruchamiamy nasz serwer przez app.listen.
Jeżeli jednak przy podłączeniu do bazy danych wystąpił błąd, to nie ma w tym przypadku po co nawet uruchamiać serwera, wypisujemy więc do konsoli wiadomość o błędzie.
const PORT = process.env.PORT || 3000;
const uriDb = process.env.DB_HOST;
const connection = mongoose.connect(uriDb, {
useUnifiedTopology: true,
useFindAndModify: false,
});
connection
.then(() => {
app.listen(PORT, function () {
console.log(`Server running. Use our API on port: ${PORT}`);
});
})
.catch(err =>
console.log(`Server not running. Error message: ${err.message}`),
);
2.3 Routes
Plik routingu api/index.js jest u nas dość przejrzysty:
const express = require('express');
const router = express.Router();
const ctrlTask = require('../controller');
router.get('/tasks', ctrlTask.get);
router.get('/tasks/:id', ctrlTask.getById);
router.post('/tasks', ctrlTask.create);
router.put('/tasks/:id', ctrlTask.update);
router.patch('/tasks/:id/status', ctrlTask.updateStatus);
router.delete('/tasks/:id', ctrlTask.remove);
module.exports = router;
Importujemy kontroler i dla każdej trasy i niezbędnej metody HTTP wywołujemy odpowiednią metodę kontrolera ctrlTask. Przy dalszej pracy nad aplikacją mogą tu pojawić się programy pośredniczące do autoryzacji niektórych tras, walidacji przekazywanych danych i tak dalej.
2.4 Kontrolery
Tu umieścimy logikę pracy naszej aplikacji. Faktyczny kontroler to w naszym przypadku handler - funkcja, która ma za zadanie opracować wynik dla danego route-a, zwraca się więc do bazy danych przez nasz service i z otrzymanym rezultatem wraca do aplikacji.
Aby otrzymać listę wszystkich zadań z bazy, wystarczy prosta funkcja:
const get = async (req, res, next) => {
try {
const results = await service.getAlltasks();
res.json({
status: 'success',
code: 200,
data: {
tasks: results,
},
});
} catch (e) {
console.error(e);
next(e);
}
};
Zwracamy się do naszego serwisu i pytamy bazę o wszystkie bieżące zadania naszej TO-DO listy:
const results = await service.getAlltasks();
Następnie przesyłamy otrzymany wynik do klienta:
res.json({
status: 'success',
code: 200,
data: {
tasks: results,
},
});
W przypadku błędu, wysyłamy go dalej przez next(err), do funkcji obsługi błędów w pliku server.js.
Kontroler do opracowywania zapytania po id jest podobny do poprzedniego, z pewną istotną różnicą. W przypadku, w którym serwis niczego nam nie zwrócił z bazy danych, zwracamy odpowiedź 404 - niczego nie znaleziono, ponieważ oznacza to, że task o podanym id nie istnieje:
const getById = async (req, res, next) => {
const { id } = req.params;
try {
const result = await service.getTaskById(id);
if (result) {
res.json({
status: 'success',
code: 200,
data: { task: result },
});
} else {
res.status(404).json({
status: 'error',
code: 404,
message: `Not found task id: ${id}`,
data: 'Not Found',
});
}
} catch (e) {
console.error(e);
next(e);
}
};
Dokładnie tak samo postępujemy dla kontrolerów aktualizacji zadania i usunięcia - jeżeli niczego nie znaleziono, zwracamy błąd 404.
2.5 Serwis pracy z bazą danych
Serwis z operacjami na bazie danych również jest dość prosty:
const Task = require('./schemas/task');
const getAlltasks = async () => {
return Task.find();
};
const getTaskById = id => {
return Task.findOne({ _id: id });
};
const createTask = ({ title, text }) => {
return Task.create({ title, text });
};
const updateTask = (id, fields) => {
return Task.findOneAndUpdate(
{ _id: id },
{ $set: fields },
{ new: true }
);
};
const removeTask = id => {
return Task.findByIdAndRemove({ _id: id });
};
module.exports = {
getAlltasks,
getTaskById,
createTask,
updateTask,
removeTask,
};
Mamy pięć funkcji, które wykonują podstawowe operacje dla naszego prostego API.
Aby otrzymać wszystkie zadania, wykorzystujemy metodę find, którą wywołujemy na modelu i zwracamy rezultat (promise) do kontrolera:
const getAlltasks = async () => {
return Task.find();
};
Otrzymanie konkretnego zadania po id. Wywołujemy metodę findOne, która odnajduje maksymalnie jeden wynik zgodnie z warunkiem { _id: id }. Jeśli metoda niczego nie znajdzie, to zwrócona zostanie wartość null, co poskutkuje następnie błędem 404 zgodnie z logiką kontrolera.
const getTaskById = id => {
return Task.findOne({ _id: id });
};
Utworzenie nowego zadania. Wywołujemy w modelu metodę create:
const createTask = ({ title, text }) => {
return Task.create({ title, text });
};
Aktualizujemy zadanie metodą findOneAndUpdate, jako pierwszy parametr przekazujemy warunek wyszukiwania - czyli wartość pola _id, jako drugi - obiekt z operatorem $set i polami, które należy zaktualizować. Flaga new w trzecim parametrze wskazuje, że metoda powinna zwrócić już zaktualizowany dokument.
const updateTask = (id, fields) => {
return Task.findOneAndUpdate(
{ _id: id },
{ $set: fields },
{ new: true }
);
};
I ostatnia operacja, usunięcie zadania z bazy danych. Wykorzystujemy metodę Mongoose findByIdAndRemove, do której przekazujemy id zadania, a metoda znajduje i usuwa je z bazy danych.
const removeTask = id => {
return Task.findByIdAndRemove({ _id: id });
};
2.6 Schemat
Ostatnim, co zostało nam do przeanalizowania jest plik utworzenia schematu naszej kolekcji zadań.
const {Schema, model} = require('mongoose');
const task = new Schema(
{
title: {
type: String,
minlength: 2,
maxlength: 70,
},
text: {
type: String,
minlength: 3,
maxlength: 170,
},
isDone: {
type: Boolean,
default: false,
},
},
{ versionKey: false, timestamps: true },
);
const Task = model('task', task);
module.exports = Task;
Wszystkie obecne tu elementy omówiliśmy już wcześniej, ale powtórzmy na tym przykładzie.
- Tworzymy schemat z trzema polami title, text, isDone.
- Określamy typ przechowywanych wartości i nakładamy ograniczenia.
Z nowych konceptów, pojawiły się następujące parametry jako opcje przy tworzeniu schematu.
{ versionKey: false, timestamps: true }
Pierwsza właściwość versionKey ustawiona na false, wyłącza pewne domyślne zachowanie. Mongoose normalnie dodaje wersjonowanie dokumentu - właściwość __v, który wskazuje ile razy dany dokument był aktualizowany.
W zasadzie jest to potrzebne dla dokumentów ze skomplikowaną strukturą, a ponieważ struktura naszego schematu jest dość prosta, wyłączmy niepotrzebną wartość.
Druga opcja dodaje do naszego schematu dwie dodatkowe właściwości, czas utworzenia dokumentu createdAt i czas aktualizacji updatedAt.
Mongoose będzie automatycznie dodawać te pola przy utworzeniu, a przy zmianie aktualizował pole updatedAt, dzięki czemu my nie musimy o tym pamiętać.
W wyniku tego, dokument w naszej kolekcji tasks powinien wyglądać tak, przechowując wszystkie zdefiniowane właściwości:
{
"isDone": false,
"_id": "5f8e3067975b9d23a0dbd270",
"title": "My work",
"text": "Pain and pain!",
"createdAt": "2020-10-20T00:33:43.492Z",
"updatedAt": "2020-10-20T00:43:16.961Z"
}
Na tym zakończyliśmy analizę prostej aplikacji REST API, z podłączoną biblioteką Mongoose.