
Relacje między tabelami w MongoDB - mongoose virtuals i populating
Relacje w nierelacyjnych bazach danych
Rozwinięcie skrótu No-SQL nie oznacza jak wielu myśli, "Nie-SQL", a bardziej "Not only SQL". Nierelacyjne bazy danych, jak je czasem po polsku nazywają, typu MongoDB, czy np. MariaDB nie posiadają charakterystycznych dla SQL relacji między tabelami opartych na primary_key i foreign_key. Na całe szczęście ORM jakim jest mongoose daje nam możliwość budowanie wirtualnych relacji i populowania danych pomiędzy kolekcjami. Na tym się właśnie postaram skupić w tym wpisie.
Wpis ten będzie jednym z kilku etapów nauki podstaw (podkreślam, że wciąż będą to tylko absolutne podstawy, potrzebne do w miarę płynnego poruszania się po świecie mikroserwisów i node.js). W skład wchodzą:
- Node.JS, Express.JS & MongoDB RESTful starter
- Relacje między tabelami w MongoDB - mongoose virtuals i populating
Ilość wpisów o tej tematyce będzie rosła i będą one uzupełniane na liście powyżej.
Początkowy szablon projektu
Aby nie powielać treści wcześniejszych wpisów na temat budowy prostych serwerów w node.JS i express oraz podpinania do nich MongoDB, etap budowy wyjściowego serwera postaram się skrócić do minimum i maksymalnie uprościć. Projekt ten różnił się będzie od poprzednich praktycznie tylko tym, że będzie zawierał i wykorzystywał dwa oddzielne, w późniejszej części połączone ze sobą, modele danych (kolekcje). Nie będziemy też budować pełnej funkcjonalności CRUD dla obu modeli, w zupełności wystarczą nam ścieżki pozwalające na dodawanie i prezentowanie zasobów (GET i POST).
Struktura projektu będzie więc wyglądać następująco.
project_folder/
models/
Event.js
Talk.js
routes/
events.js
talks.js
.env
package.json
server.js
Standardowo zaczynamy od instalacji pakietów.
npm i express dotenv mongoose
npm i -D nodemon
Natomiast podstawowy plik serwera będzie wyglądał następująco.
const express = require("express");
const mongoose = require("mongoose");
require("dotenv").config();
const PORT = process.env.PORT || 5000;
// connect mongoDB
mongoose.connect(
process.env.DB_CONNECT,
{ useNewUrlParser: true, useUnifiedTopology: true },
() => console.log("Connected to mongoDB...")
);
// initialize express app
const app = express();
// middleware
app.use(express.json());
// initialize listener
app.listen(PORT, () => {
console.log("Server started on port ${PORT}...");
});
Oczywiście w pliku .env musimy teraz zapisać nasz bardzo prywatny string, do podłączenia cloudowej wersji MongoDB, no i opcjonalnie PORT, na którym będziemy nasłuchiwać. Jeżeli coś w tym pliku jak na razie jest nielogiczne, to odsyłam do poprzednich wpisów, gdzie znacznie bardziej szczegółowo opisuje wszystkie te rzeczy. Teraz gdy nasz serwer jest już możliwy do odpalenia, powinniśmy dodać w pliku package.json, w elemencie scripts polecenia odpalające go.
...
"scripts": {
"start": "node server",
"dev": "nodemon server"
}
...
Dzięki poleceniu npm run dev odpalimy serwer w trybie watch, który dzięki pakietowi Nodemon będzie nasłuchiwał na zmiany w plikach i przy ich zaobserwowaniu przeładuje nam aplikacje. Przydatna rzecz.
Teraz nie pozostało nam nic innego jak tylko dodać nasze modele dla dwóch tabeli w bazie danych, odpowiednio Event.js i Talk.js.
const mongoose = require("mongoose");
const eventSchema = new mongoose.Schema({
name: {
type: String,
required: true,
unique: true,
},
description: {
type: String,
required: true,
},
cost: {
type: Number,
required: true,
},
createdAt: {
type: Date,
default: Date.now,
},
});
module.exports = mongoose.model("Event", eventSchema);
const mongoose = require("mongoose");
const talkSchema = new mongoose.Schema({
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
length: {
type: Number,
required: true,
},
event: {
type: mongoose.Schema.ObjectId,
ref: "Event",
required: true,
},
createdAt: {
type: Date,
default: Date.now,
},
});
module.exports = mongoose.model("Talk", talkSchema);
Są to bardzo proste modele dla dwóch powiązanych ze sobą zbiorów danych. W tym przykładzie każdy obiekt z kolekcji Talks odpowiada któremuś obiektowi z kolekcji Events. Każdy Event może mieć wiele Wykładów. Pierwszą rzeczą, którą musimy zrobić, aby powiązać dane z tych dwóch kolekcji, to w modelu Talk dodać pole wiążące dany obiekt z obiektem z Events. Stąd właśnie pole event, którego typ to ObjectId. Jest to charakterystyczny typ dla modelów mongoose (MongoDB), stąd musimy go wyciągnąć z obiektu Schema pakietu mongoose. W tym polu będziemy musieli wprowadzić do obiektu wykładu (talk) _id skojarzonego z nim eventu. W ref przekazujemy, do jakiego zewnętrznego modelu nawiązujemy. Oczywiście, aby każdy wykład był przypisany do jakiegoś eventu, pole to jest wymagane.
Teraz musimy wykorzystać modele do obsługi danych. Stwórzmy do tego, dla zachowania ładu i porządku, dwa oddzielne pliki ze ścieżkami (routery), events.js i talks.js. Jak wspomniałem na wstępie, nie skupiamy się na pełnej funkcjonalności CRUD, ani aby API było RESTowe. Pominiemy więc edycję danych, a w routerach będą jedynie metody do dodawania i odczytu danych.
const router = require("express").Router();
// import model
const Event = require("../models/Event");
// @desc Get all events
// @route GET /api/events
router.get("/", async (req, res) => {
try {
const events = await Event.find();
res.status(200).json({ data: events });
} catch (error) {
res.status(500).json({ error });
}
});
// @desc Create event
// @route POST /api/events
router.post("/", async (req, res) => {
try {
const event = await Event.create(req.body);
res.status(200).json({ data: event });
} catch (error) {
res.status(400).json({ error });
}
});
module.exports = router;
const router = require("express").Router();
// import model
const Talk = require("../models/Talk");
// @desc Get all talks
// @route GET /api/talks
router.get("/", async (req, res) => {
try {
const talks = await Talk.find();
res.status(200).json({ data: talks });
} catch (error) {
res.status(500).json({ error });
}
});
// @desc Create talk
// @route POST /api/talks
router.post("/", async (req, res) => {
try {
const talk = await Talk.create(req.body);
res.status(200).json({ data: talk });
} catch (error) {
res.status(400).json({ error });
}
});
module.exports = router;
Musimy również pamiętać, o zaimportowaniu i wykorzystaniu naszych zbudowanych wyżej routerów. Zazwyczaj dla większej przejrzystości kodu i aby wszystko było rozdzielone według zadań, najpierw na górze pliku importujemy routery, a używamy na samym dole przed nasłuchem. Tu jednak, aby aplikacja była prostsza i czytelniejsza postanowiłem użycie i import zmieścić dla każdego routera w jednej linijce. Pomijamy po prostu oddzielne deklarowanie i przypisywanie routerów do zmiennych.
...
// middleware
app.use(express.json());
// roters
app.use("/api/events", require("./routes/events"));
app.use("/api/talks", require("./routes/talks"));
// initialize listener
app.listen(PORT, () => {
console.log('Server started on port \${PORT}...');
});
Do tej pory nie pojawiło się tutaj kompletnie nic nowego. Standardowe modele, połączenie z MongoDB, routing. Wszystko to przewinęło się już we wcześniejszych artykułach.
Pora więc dodać wyczekiwane połączenie dwóch zasobów, tak aby odczytując wszystkie Eventy z bazy danych, dla każdego z nich przypisać (i wyświetlić), powiązane z nim za pomocą _id wykłady.
Dane niezbędne do testów
Teraz na potrzeby testów będziemy potrzebowali uzupełnić nasze dwie kolekcje Events i Talks. Poniżej przygotowałem gotowe obiekty, które można bezpośrednio dodać do bazy danych. Polecam wykorzystać do tego cURL lub Postman. Pamiętajmy tylko, że pola event dla Talks muszą odpowiadać konkretnym eventą, a dokładnie ich _id. Dlatego najpierw dodajmy eventy, a potem talks z podmienionymi polami event.
// examples of Events
[
{
"name": "Node Conference",
"description": "Description of Node Conference.",
"cost": 100
},
{
"name": "Fron-End Day",
"description": "Description of Front-End Day.",
"cost": 200
}
]
// examples of Talks
[
{
"title": "Syntax of Node.JS",
"description": "Description Talk",
"length": 2,
"event": "5dc5d9fd51e3704d70860d28"
},
{
"title": "How to use MongoDB",
"description": "Description of Talk",
"length": 1,
"event": "5dc5d9fd51e3704d70860d28"
},
{
"title": "Vue.JS syntax",
"description": "Description of Talk",
"length": 2,
"event": "5dc5da2051e3704d70860d29"
},
{
"title": "Vue.JS vs. React",
"description": "Description of Talk",
"length": 1,
"event": "5dc5da2051e3704d70860d29"
},
{
"title": "How not to commit suicide using Webpack",
"description": "Description of Talk",
"length": 3,
"event": "5dc5da2051e3704d70860d29"
}
]
Spośród pięciu obiektów kolekcji talks, dwa pierwsze powiązane są z eventem "Node Conference", a trzy kolejne do eventu "Front_End Day". Teraz możemy połączyć nasze dwie kolekcje.
Wirtualne właściwości
Najprościej mówiąc, wirtualne właściwości (virtual properties), są to pola w modelu, które nie zostają zapisane w bazie danych, ale tylko zwrócone dla wywołania. Mogą one prezentować jakieś dane przeliczane na podstawie innych pól modelu, ale możemy też przypisywać do nich dane z innych kolekcji, w jakiś sposób powiązane z naszym rekordem. Mogą się więc one nam przydać na dwa sposoby.
Populate Virtuals: virtualne pole "talks" w modelu Events
Na początek zajmiemy się przekazaniem wykładów do obiektów Event. Każdy z wykładów posiada _id eventu więc musimy się nim posłużyć.
W pierwszej kolejności musimy dodać do naszego obiektu Schemy dla Events opcje, mówiącą, że Schema może przyjmować virtualne właściwości.
const eventSchema = new mongoose.Schema(
{
....
},
{
toJSON: {
virtuals: true
}
}
);
module.exports = mongoose.model("Event", eventSchema);
Możemy teraz zadeklarować wirtualne pole dla Schemy Event. Na końcu pliku, ale przed eksportem, dodajemy blok kodu deklarujący virtual property i do jakich wartości ma się odnosić.
...
eventSchema.virtual("talks", { // nazwa virtualnego pola
ref: "Talk", // model, którego chcemy użyć
localField: "\_id", // znajdź talk gdzie 'localField'
foreignField: "event", // jest równe 'foreignField'
justOne: false // wartości talks dla eventu może być więcej nize 1
});
module.exports = mongoose.model("Event", eventSchema);
W budowaniu tego typu zależności chodzi o to, aby w kolekcji Talks znaleźć wszystkie obiekty, których pole event (foreignField dla obiektu Event) miało tę samą wartość co pole _id kolekcji Events (czyli jej localField). Podobieństwo nazw z privateKey i foreignKey z SQL jest nieprzypadkowa. Opcja justOne: false zaznacza, że każdy event, może mieć powiązany więcej niż jeden wykład, a pole talks będzie Arrayem, a nie pojedynczym dokumentem.
Tak zbudowane wirtualne pole możemy teraz wywołać, a dokładniej zpopulować, czyli dodać do wyników zapytania kolekcji Events. W tym celu musimy powiedzieć metodzie zwracającej kolekcje, czyli Event.find() w pliku routes/events.js, że w wyniku ma również przedstawić pole talks. Stąd nasz pierwszy request zmieni się następująco:
...
// @desc Get all events
// @route GET /api/events
router.get("/", async (req, res) => {
try {
const events = await Event.find().populate("talks")
res.status(200).json({ data: events });
} catch (error) {
res.status(500).json({ error });
}
});
...
Dzięki temu dla każdego obiektu eventu w odpowiedzi platformy otrzymujemy również powiązane z nim wykłady.
{
"data": [
{
"_id": "5dc5d9fd51e3704d70860d28",
"name": "Node Conference",
"description": "Description of Node Conference.",
"cost": 100,
"createdAt": "2019-11-08T21:11:25.295Z",
"**v": 0,
"talks": [
{
"_id": "5dc5da5c51e3704d70860d2a",
"title": "Syntax of Node.JS",
"description": "Description Talk",
"length": 2,
"event": "5dc5d9fd51e3704d70860d28",
"createdAt": "2019-11-08T21:13:00.699Z",
"**v": 0
},
{
"_id": "5dc5da7851e3704d70860d2b",
"title": "How to use MongoDB",
"description": "Description of Talk",
"length": 1,
"event": "5dc5d9fd51e3704d70860d28",
"createdAt": "2019-11-08T21:13:28.320Z",
"**v": 0
}
],
"id": "5dc5d9fd51e3704d70860d28"
},
{
"_id": "5dc5da2051e3704d70860d29",
"name": "Fron-End Day",
"description": "Description of Front-End Day.",
"cost": 200,
"createdAt": "2019-11-08T21:12:00.343Z",
"**v": 0,
"talks": [
{
"_id": "5dc5da9f51e3704d70860d2c",
"title": "Vue.JS syntax",
"description": "Description of Talk",
"length": 2,
"event": "5dc5da2051e3704d70860d29",
"createdAt": "2019-11-08T21:14:07.080Z",
"__v": 0
},
{
"_id": "5dc5dab051e3704d70860d2d",
"title": "Vue.JS vs. React",
"description": "Description of Talk",
"length": 1,
"event": "5dc5da2051e3704d70860d29",
"createdAt": "2019-11-08T21:14:24.930Z",
"__v": 0
},
{
"_id": "5dc5dadc51e3704d70860d2e",
"title": "How not to commit suicide using Webpack",
"description": "Description of Talk",
"length": 3,
"event": "5dc5da2051e3704d70860d29",
"createdAt": "2019-11-08T21:15:08.043Z",
"__v": 0
}
],
"id": "5dc5da2051e3704d70860d29"
}
]
}
Możemy również decydować, jakie dokładnie dane z kolekcji Talks zostaną nam zwrócone. W tym celu musimy ponownie nieznacznie przerobić zapytanie, a dokładnie sam argument przekazywany do metody .populate(). Zamiast mówić ogólnie, że chcemy zpopulować obiekt do pola talks przekażemy, z jakiego obiektu i dokładnie jakie pola chcemy zobaczyć.
...
// @desc Get all events
// @route GET /api/events
router.get("/", async (req, res) => {
try {
const events = await Event.find().populate({
path: "talks",
select: "name description"
})
res.status(200).json({ data: events });
} catch (error) {
res.status(500).json({ error });
}
});
...
Takie wywołanie ograniczy nam zwracane pola do name i description dla każdego obiektu wykładu. _id przekazywane jest zawsze.
"data": [
{
"_id": "5dc5d9fd51e3704d70860d28",
"name": "Node Conference",
"description": "Description of Node Conference.",
"cost": 100,
"createdAt": "2019-11-08T21:11:25.295Z",
"__v": 0,
"talks": [
{
"_id": "5dc5da5c51e3704d70860d2a",
"description": "Description Talk",
"event": "5dc5d9fd51e3704d70860d28"
},
{
"_id": "5dc5da7851e3704d70860d2b",
"description": "Description of Talk",
"event": "5dc5d9fd51e3704d70860d28"
}
],
"id": "5dc5d9fd51e3704d70860d28"
}
]
Przekazywanie za pomocą virtuals ilości obiektów
Za pomocą virtuals możemy również przekazać ile mamy w bazie danych wykładów skojarzonych z danym eventem. Zadeklarowanie tego virtual property jest bardzo podobne do poprzedniego, różni się jedynie użyciem opcji count: true, która zwraca ilość obiektów, spełniających warunek równości localField i foreignField.
eventSchema.virtual("talksCount", {
ref: "Talk",
localField: "_id",
foreignField: "event",
count: true, // zwraca ilość dokumentów
});
Wywołanie w routerze jest identyczne, dodajemy po prostu kolejną metodę .populate(). Dzięki temu w obiekcie odpowiedzi otrzymamy, dla każdego eventu dodatkowe pole "talksCount", mówiące, ile każdy event ma wykładów.
...
// @desc Get all events
// @route GET /api/events
router.get("/", async (req, res) => {
try {
const events = await Event.find()
.populate({
path: "talks",
select: "name description"
});
.populate("talksCount");
res.status(200).json({ data: events });
} catch (error) {
res.status(500).json({ error });
}
});
Podsumowanie
W tym wpisie chciałem rozpocząć i ukierunkować dalszą pracę związaną z poznawaniem bardziej zaawansowanych metod pracy z danymi w MongoDB. W kolejnych wpisach będziemy omawiać statyczne metody modelu, zaawansowane deklarowanie jakie dane i w jakiej formie ma zwrócić serwer w zależności od parametrów requestu. Omówimy np. sortowanie i ograniczanie zwracanych danych itp. Czeka więc nas jeszcze wiele pracy.