Документни бази
Накратко: Документната база, като MongoDB, пази всеки запис като документ с вложени полета и масиви, без една и съща схема за всички записи.
Проблем
Продукти, които не си приличат
В таблицата products всеки продукт има едни и същи колони: име, категория, цена, наличност. Магазинът иска да показва повече. За хляба теглото и алергените, за кафето произхода и изпичането, за препарата обема. Таблицата дава два лоши избора. Първият е десетки колони, които при повечето продукти са NULL. Вторият е отделна таблица „свойство, стойност“, в която всичко е текст и всяко четене е още един JOIN.
Поръчката има подобен проблем. За да я покажеш на клиента, сглобяваш я от четири таблици:
Сайтът показва поръчката винаги цялата: клиента, редовете, цените. А базата всеки път я сглобява наново.
Идеята
Документът
Документната база пази всеки запис като документ: обект с полета, като JSON. Документите са в колекция, която отговаря на таблицата, но не определя полетата. Всеки документ има своите. За разлика от key-value базата, документната вижда вътре в стойността и търси по полетата ѝ.
Полето е симулатор с командите на MongoDB. Конзолата му се казва mongosh, а командите започват с db. и името на колекцията:
Хлябът има weight_g и allergens, кафето origin и roast, препаратът volume_ml. Стойността може да е масив (allergens) или друг документ. _id е първичният ключ: всеки документ го има и той не се повтаря.
По-точно, за любопитните
Ако не дадеш _id, MongoDB слага сам ObjectId('…'): 12 байта, в които са времето на създаване и случайна част. Симулаторът ги дава подред (ObjectId('6720a0000000000000000001'), после …0002), за да е еднакъв изходът всеки път. Истинските не вървят така. Симулаторът връща документите в реда, в който са влезли, ако няма sort(). MongoDB не обещава никакъв ред без sort(). Изходът е като в mongosh 2: текстът е в единични кавички, а дългите документи се пренасят по едно поле на ред.
Пробвай
Търсене в документите
find(условие, проекция) търси. Условието е документ с полетата, които трябва да съвпаднат. { price: { $lt: 3 } } значи „цена под 3“: $lt е „по-малко“, $lte „по-малко или равно“, $gt и $gte „по-голямо“, $ne „различно“, $in „едно от изброените“. $exists пита дали полето го има изобщо. Проекцията казва кои полета да се върнат: 1 значи „да“, а _id: 0 маха _id, който иначе идва винаги. .sort({ price: 1 }) подрежда възходящо (-1 е низходящо), а countDocuments брои:
Условието { allergens: 'мляко' } намира документите, в чийто масив има 'мляко'. Внимавай: { allergens: ['мляко'] } търси точно този масив, с един елемент и нищо повече.
Покажи имената на продуктите, които съдържат глутен, без _id.
Решение
db.products.find({ allergens: 'глутен' }, { name: 1, _id: 0 })
db.products.find({ allergens: 'глутен' }, { name: 1, _id: 0 }) намира хляба и кифлата. С ['глутен'] кифлата изпада, защото нейният масив е ['глутен', 'мляко']. А с allergen вместо allergens не излиза нищо, и базата не казва защо.
Виж го
Грешка в името на полето
Свободата има цена. Таблицата знае имената на колоните си: UPDATE на колона prise, която я няма, дава грешка. Колекцията не знае никакви имена. Кафето поевтинява на 12,90 €, но в името на полето има грешка:
Базата отговаря modifiedCount: 1 и не дава грешка. Тя е добавила ново поле prise, а price е още 14.9. Затова кафето не излиза сред продуктите под 13 €. Такава грешка се вижда чак когато някой забележи старата цена на сайта.
Затова програмата, която пише в документна база, сама пази формата: един клас или една схема в кода за всеки вид документ. MongoDB може и да проверява документите при запис по правила за колекцията.
По-точно, за любопитните
Правилата се задават с db.createCollection('products', { validator: { $jsonSchema: … } }) или по-късно с командата collMod. Те казват кои полета са задължителни и от какъв тип са. Симулаторът няма такава проверка.
Идеята
Вграждане или препратка
Поръчката от началото може да е един документ, с клиента и редовете вътре в нея. Това се казва вграждане (embedding):
findOne я връща цялата с едно четене, без JOIN. До поле във вградения документ се стига с точка: 'customer.city'. Пътят е в кавички, защото има точка. Същото работи и през масив. В това поле колекцията orders вече има поръчките 53–60 на магазина, и 'items.product_id': 1 намира тези, в които някой ред е за белия хляб:
Името и цената в редовете са копия, и това е нарочно: поръчката пази цената от деня на покупката, както unit_price в order_items. Името на клиента също е копие. Ако Мария смени името си, старите поръчки остават със старото. За поръчката това е вярно, но профилът на клиента се пази в своя колекция, а поръчката носи само customer.id.
Вграждаш, когато частта принадлежи на документа, чете се заедно с него и е малко: една поръчка има няколко реда, а не хиляди. Слагаш препратка (referencing), когато нещо се споделя от много документи или расте без край. Отзивите за кафето са такива: много са, пишат се по всяко време и всеки има свой автор. Те са в своя колекция, а product_id сочи продукта, както външен ключ:
Вграждането е денормализация: по-бързо четене срещу копия, които могат да се разминат.
По-точно, за любопитните
Документ в MongoDB е най-много 16 MB, затова масив, който расте без край, рано или късно не се събира в документа. Препратките се свързват в aggregate с етапа $lookup (с from, localField, foreignField и as), който прави нещо като LEFT JOIN. Симулаторът го знае само в този вид. Ако ти трябва $lookup във всяка заявка, данните ти вероятно са релационни.
Пробвай
Промени вътре в документа
updateOne(условие, промяна) променя първия намерен документ, а updateMany всички. Промяната е с оператор: $set слага стойност (и създава полето, ако го няма), $inc увеличава число, $push добавя в масив, $unset маха поле. В това поле пак са поръчките 53–60. Поръчка 60 е доставена, в нея се добавя масло, а белият хляб става три:
'items.$.qty' значи „qty на реда, който условието намери“. Условието 'items.product_id': 1 намира реда на белия хляб, а $ е мястото му в масива. Документът се променя целият наведнъж: никой не може да прочете поръчката наполовина променена.
Без оператор mongosh отказва: updateOne({ _id: 60 }, { status: 'delivered' }) дава грешка Update document requires atomic operators. А $set на целия масив items го заменя, и старите редове изчезват.
Виктория (поръчка 58) иска още един пълнозърнест хляб (продукт 2) и две кисели млека: { product_id: 6, name: 'Кисело мляко', qty: 2, price: 1.1 }.
Решение
db.orders.updateOne({ _id: 58, 'items.product_id': 2 }, { $inc: { 'items.$.qty': 1 } })
db.orders.updateOne({ _id: 58 }, { $push: { items: { product_id: 6, name: 'Кисело мляко', qty: 2, price: 1.1 } } })
Две промени. Първо $inc на 'items.$.qty' с условие 'items.product_id': 2, после $push на новия ред. С 'items.0.qty' се увеличава първият ред, а той е белият хляб, не пълнозърнестият. А $set на items заменя целия масив с един ред, и хлябовете изчезват.
Идеята
Агрегация
Въпросите „колко“ и „по колко“ (оборот по градове, най-продавани продукти) се пишат с aggregate: масив от етапи, през които документите минават един след друг. Това е конвейер (pipeline):
$matchфилтрира, като WHERE;$unwindразделя поръчката на по един документ за всеки ред отitems;$groupсъбира документите в групи по_id, като GROUP BY, със$sum,$avg,$minи$max;$projectоставя или пресмята полета, а$sortи$limitподреждат и режат.
Оборотът по градове без отменените поръчки, и после броят на поръчките:
{ $sum: 1 } брои документите в групата, като COUNT(*). Без $unwind групите щяха да събират поръчки, а не редове. $round закръгля до цент: сумата на дробни числа иначе излиза като 22.799999999999997 вместо 22,80 €, както при всички числа с плаваща запетая.
По-точно, за любопитните
MongoDB не обещава ред на групите след $group, затова след него идва $sort, а при равни стойности и втори ключ (_id: 1). Симулаторът дава групите в реда, в който са се появили. Дробните суми той смята малко иначе от MongoDB и понякога се разминава в последната цифра. След $round двете дават едно и също.
Упражнение
Пратки, алергени и най-продавани
В полетата на задачите са поръчките 53–60 и продуктите от началото на урока.
1. Куриерът идва за платените поръчки.
Смени статуса на всички поръчки със status: 'paid' на 'shipped'.
Решение
db.orders.updateMany({ status: 'paid' }, { $set: { status: 'shipped' } })
db.orders.updateMany({ status: 'paid' }, { $set: { status: 'shipped' } }). Платени са 55 и 59. updateOne променя само първата намерена (55), а 59 остава paid.
2. Оказва се, че в кифлата с масло има и яйца.
Добави 'яйца' към алергените на кифлата с масло (продукт 3).
Решение
db.products.updateOne({ _id: 3 }, { $push: { allergens: 'яйца' } })
db.products.updateOne({ _id: 3 }, { $push: { allergens: 'яйца' } }) добавя в края на масива. $set с ['яйца'] заменя масива, и глутенът и млякото изчезват. А alergens с едно „l“ създава ново поле, точно като prise.
3. Складът иска да знае кое се продава най-много.
Кои продукти са продадени в 5 или повече бройки (без отменените поръчки)? Покажи ги от най-продавания надолу, а при равни бройки по име.
Решение
db.orders.aggregate([
{ $match: { status: { $ne: 'cancelled' } } },
{ $unwind: '$items' },
{ $group: { _id: '$items.name', sold: { $sum: '$items.qty' } } },
{ $match: { sold: { $gte: 5 } } },
{ $sort: { sold: -1, _id: 1 } },
])
$match маха отменените, $unwind разделя редовете, $group по '$items.name' събира $sum: '$items.qty', после втори $match върху новото поле sold (като HAVING) и $sort: { sold: -1, _id: 1 }. Без $unwind стойността на '$items.qty' е масив, а $sum в $group прескача масивите. Всичко излиза 0 и нищо не минава $gte: 5.
Идеята
Кога документна база, кога не
Документната база е добра, когато записът се чете и пише цял, а формата му се мени: каталог с различни продукти, профили и настройки, съдържание на сайт, събития от приложение, поръчка, която се показва наведнъж.
Тя е лош избор, когато данните са свързани във всички посоки. Магазинът пита „кой клиент какво е купил“, „кои продукти се купуват заедно“, „оборот по категории и месеци“. Всеки от тези въпроси минава през клиенти, поръчки и продукти наведнъж. В SQL това са JOIN-ове върху нормализирани таблици, в които всеки факт е записан веднъж. Тук са копия, които трябва да се пазят еднакви, или $lookup във всяка заявка.
Същото е, когато една операция пипа много документи: плащане, което намалява наличността на пет продукта и записва поръчката. Промяната на един документ е цялата или никаква. За няколко документа трябва транзакция, която е по-сложна и по-бавна от единичния запис.
По-точно, за любопитните
Документни бази са MongoDB (най-разпространената, с mongosh и aggregate като в симулатора), Couchbase, Apache CouchDB, Firestore в Google Cloud и Amazon DocumentDB, който приема командите на MongoDB. MongoDB има транзакции върху много документи от версия 4.0. PostgreSQL има тип jsonb: колона с документ в обикновена таблица, с индекси и оператори за полетата му. Често това е добрият среден път: таблици за свързаните данни и jsonb за различните свойства на продуктите.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Естествен ключ и денормализация · ACID: всичко или нищо · Индекси · Key-value и кешът
- Продължава в: Как се избира база
- Задачи по темата: Видове бази данни