Към съдържанието
wannacode

Документни бази

Накратко: Документната база, като MongoDB, пази всеки запис като документ с вложени полета и масиви, без една и съща схема за всички записи.

Проблем

Продукти, които не си приличат

В таблицата products всеки продукт има едни и същи колони: име, категория, цена, наличност. Магазинът иска да показва повече. За хляба теглото и алергените, за кафето произхода и изпичането, за препарата обема. Таблицата дава два лоши избора. Първият е десетки колони, които при повечето продукти са NULL. Вторият е отделна таблица „свойство, стойност“, в която всичко е текст и всяко четене е още един JOIN.

Поръчката има подобен проблем. За да я покажеш на клиента, сглобяваш я от четири таблици:

SQLбаза: магазин

Сайтът показва поръчката винаги цялата: клиента, редовете, цените. А базата всеки път я сглобява наново.

Идеята

Документът

Документната база пази всеки запис като документ: обект с полета, като JSON. Документите са в колекция, която отговаря на таблицата, но не определя полетата. Всеки документ има своите. За разлика от key-value базата, документната вижда вътре в стойността и търси по полетата ѝ.

Полето е симулатор с командите на MongoDB. Конзолата му се казва mongosh, а командите започват с db. и името на колекцията:

документна (като MongoDB)симулатор

Хлябът има 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 брои:

документна (като MongoDB)симулатор

Условието { allergens: 'мляко' } намира документите, в чийто масив има 'мляко'. Внимавай: { allergens: ['мляко'] } търси точно този масив, с един елемент и нищо повече.

Покажи имената на продуктите, които съдържат глутен, без _id.

документна (като MongoDB)симулатор

Виж го

Грешка в името на полето

Свободата има цена. Таблицата знае имената на колоните си: UPDATE на колона prise, която я няма, дава грешка. Колекцията не знае никакви имена. Кафето поевтинява на 12,90 €, но в името на полето има грешка:

документна (като MongoDB)симулатор

Базата отговаря modifiedCount: 1 и не дава грешка. Тя е добавила ново поле prise, а price е още 14.9. Затова кафето не излиза сред продуктите под 13 €. Такава грешка се вижда чак когато някой забележи старата цена на сайта.

Затова програмата, която пише в документна база, сама пази формата: един клас или една схема в кода за всеки вид документ. MongoDB може и да проверява документите при запис по правила за колекцията.

По-точно, за любопитните

Правилата се задават с db.createCollection('products', { validator: { $jsonSchema: … } }) или по-късно с командата collMod. Те казват кои полета са задължителни и от какъв тип са. Симулаторът няма такава проверка.

Идеята

Вграждане или препратка

Поръчката от началото може да е един документ, с клиента и редовете вътре в нея. Това се казва вграждане (embedding):

документна (като MongoDB)симулатор

findOne я връща цялата с едно четене, без JOIN. До поле във вградения документ се стига с точка: 'customer.city'. Пътят е в кавички, защото има точка. Същото работи и през масив. В това поле колекцията orders вече има поръчките 53–60 на магазина, и 'items.product_id': 1 намира тези, в които някой ред е за белия хляб:

документна (като MongoDB)симулатор

Името и цената в редовете са копия, и това е нарочно: поръчката пази цената от деня на покупката, както unit_price в order_items. Името на клиента също е копие. Ако Мария смени името си, старите поръчки остават със старото. За поръчката това е вярно, но профилът на клиента се пази в своя колекция, а поръчката носи само customer.id.

Вграждаш, когато частта принадлежи на документа, чете се заедно с него и е малко: една поръчка има няколко реда, а не хиляди. Слагаш препратка (referencing), когато нещо се споделя от много документи или расте без край. Отзивите за кафето са такива: много са, пишат се по всяко време и всеки има свой автор. Те са в своя колекция, а product_id сочи продукта, както външен ключ:

документна (като MongoDB)симулатор

Вграждането е денормализация: по-бързо четене срещу копия, които могат да се разминат.

По-точно, за любопитните

Документ в MongoDB е най-много 16 MB, затова масив, който расте без край, рано или късно не се събира в документа. Препратките се свързват в aggregate с етапа $lookup (с from, localField, foreignField и as), който прави нещо като LEFT JOIN. Симулаторът го знае само в този вид. Ако ти трябва $lookup във всяка заявка, данните ти вероятно са релационни.

Пробвай

Промени вътре в документа

updateOne(условие, промяна) променя първия намерен документ, а updateMany всички. Промяната е с оператор: $set слага стойност (и създава полето, ако го няма), $inc увеличава число, $push добавя в масив, $unset маха поле. В това поле пак са поръчките 53–60. Поръчка 60 е доставена, в нея се добавя масло, а белият хляб става три:

документна (като MongoDB)симулатор

'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 }.

документна (като MongoDB)симулатор

Идеята

Агрегация

Въпросите „колко“ и „по колко“ (оборот по градове, най-продавани продукти) се пишат с aggregate: масив от етапи, през които документите минават един след друг. Това е конвейер (pipeline):

  • $match филтрира, като WHERE;
  • $unwind разделя поръчката на по един документ за всеки ред от items;
  • $group събира документите в групи по _id, като GROUP BY, със $sum, $avg, $min и $max;
  • $project оставя или пресмята полета, а $sort и $limit подреждат и режат.

Оборотът по градове без отменените поръчки, и после броят на поръчките:

документна (като MongoDB)симулатор

{ $sum: 1 } брои документите в групата, като COUNT(*). Без $unwind групите щяха да събират поръчки, а не редове. $round закръгля до цент: сумата на дробни числа иначе излиза като 22.799999999999997 вместо 22,80 €, както при всички числа с плаваща запетая.

По-точно, за любопитните

MongoDB не обещава ред на групите след $group, затова след него идва $sort, а при равни стойности и втори ключ (_id: 1). Симулаторът дава групите в реда, в който са се появили. Дробните суми той смята малко иначе от MongoDB и понякога се разминава в последната цифра. След $round двете дават едно и също.

Упражнение

Пратки, алергени и най-продавани

В полетата на задачите са поръчките 53–60 и продуктите от началото на урока.

1. Куриерът идва за платените поръчки.

Смени статуса на всички поръчки със status: 'paid' на 'shipped'.

документна (като MongoDB)симулатор

2. Оказва се, че в кифлата с масло има и яйца.

Добави 'яйца' към алергените на кифлата с масло (продукт 3).

документна (като MongoDB)симулатор

3. Складът иска да знае кое се продава най-много.

Кои продукти са продадени в 5 или повече бройки (без отменените поръчки)? Покажи ги от най-продавания надолу, а при равни бройки по име.

документна (като MongoDB)симулатор

Идеята

Кога документна база, кога не

Документната база е добра, когато записът се чете и пише цял, а формата му се мени: каталог с различни продукти, профили и настройки, съдържание на сайт, събития от приложение, поръчка, която се показва наведнъж.

Тя е лош избор, когато данните са свързани във всички посоки. Магазинът пита „кой клиент какво е купил“, „кои продукти се купуват заедно“, „оборот по категории и месеци“. Всеки от тези въпроси минава през клиенти, поръчки и продукти наведнъж. В SQL това са JOIN-ове върху нормализирани таблици, в които всеки факт е записан веднъж. Тук са копия, които трябва да се пазят еднакви, или $lookup във всяка заявка.

Същото е, когато една операция пипа много документи: плащане, което намалява наличността на пет продукта и записва поръчката. Промяната на един документ е цялата или никаква. За няколко документа трябва транзакция, която е по-сложна и по-бавна от единичния запис.

По-точно, за любопитните

Документни бази са MongoDB (най-разпространената, с mongosh и aggregate като в симулатора), Couchbase, Apache CouchDB, Firestore в Google Cloud и Amazon DocumentDB, който приема командите на MongoDB. MongoDB има транзакции върху много документи от версия 4.0. PostgreSQL има тип jsonb: колона с документ в обикновена таблица, с индекси и оператори за полетата му. Често това е добрият среден път: таблици за свързаните данни и jsonb за различните свойства на продуктите.

Тест

Провери се

1. Командата db.products.updateOne({ _id: 17 }, { $set: { prise: 12.9 } }) връща modifiedCount: 1. Какво е станало?
2. Кое е добре да се вгради в документа на поръчката?
3. Какво намира db.products.find({ allergens: 'мляко' })?
4. Плащането намалява наличността на пет продукта и записва поръчката. Защо тук документната база не е най-добрият избор?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·