Раздел 36 · MongoDB с нуля

Схема, валидация и антипаттерны

junior~25 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке мы копировали поля и хранили вычисленные значения, и всё держалось на аккуратности приложения. Но приложение ошибается: один разработчик запишет цену числом, другой строкой, третий забудет название. Сегодня научим MongoDB проверять документы при записи, а потом пройдёмся по ошибкам моделирования, на которые натыкаются чаще всего. Это последний урок кластера про моделирование.

Гибкая схема не значит отсутствие схемы

Представь склад, куда принимают коробки любой формы. Удобно: можно привезти что угодно. Но через год никто не знает, где что лежит, а половина коробок подписана неразборчиво. Опытный кладовщик всё равно заводит правила приёмки: на коробке должна быть наклейка с названием и весом.

С MongoDB так же. Ей не нужна заранее объявленная структура, как CREATE TABLE в PostgreSQL. Но у приложения всё равно есть ожидания: у книги должно быть название, цена должна быть числом. Если база их не проверяет, ошибки копятся молча.

Вот как это выглядит. В коллекции books никаких правил нет, и кто-то добавил книгу с ценой строкой:

db.books.insertOne({ _id: 10, title: 'Generation П', genre: 'роман', price: '700', pages: 320, year: 1999 })
{ acknowledged: true, insertedId: 10 }

Теперь витрина ищет книги дороже 500 рублей:

db.books.find({ price: { $gt: 500 } }, { title: 1, price: 1 }).sort({ _id: 1 })
[
  { _id: 1, title: 'Мастер и Маргарита', price: 690 },
  { _id: 4, title: 'Трудно быть богом', price: 520 },
  { _id: 6, title: 'Мёртвые души', price: 610 },
  { _id: 7, title: 'Грокаем алгоритмы', price: 1290 },
  { _id: 8, title: 'Совершенный код', price: 2490 }
]

Книги за 700 рублей нет. MongoDB сравнивает значения одного типа, и строка '700' не больше числа 500 и не меньше его: она не участвует в сравнении. Мы встречали эту ловушку в уроке про операторы запроса. Найти таких нарушителей помогает оператор $type:

db.books.find({ price: { $type: 'string' } }, { title: 1, price: 1 })
[ { _id: 10, title: 'Generation П', price: '700' } ]

Искать нарушения после записи поздно. Лучше не пускать их в базу.

Правила приёмки: $jsonSchema

Правила проверки задают при создании коллекции в параметре validator. Чаще всего их пишут на языке JSON Schema через оператор $jsonSchema. Заведём коллекцию издательств с правилами:

db.createCollection('publishers', {
  validator: {
    $jsonSchema: {
      bsonType: 'object',
      required: ['name', 'country'],
      properties: {
        name: { bsonType: 'string', minLength: 2 },
        country: { enum: ['Россия', 'США', 'Великобритания'] },
        founded: { bsonType: 'int', minimum: 1700 }
      }
    }
  }
})
{ ok: 1 }

Разберём правила:

  • required: поля, без которых документ не примут;
  • properties: правила для отдельных полей;
  • bsonType: тип поля в терминах BSON, например 'string', 'int', 'double', 'date', 'array';
  • minLength, minimum: нижние границы для строк и чисел;
  • enum: список допустимых значений.

Поля, которые не упомянуты в схеме, по-прежнему разрешены. Схема описывает обязательный минимум, а не полный список полей. Запретить лишние поля можно правилом additionalProperties: false.

Правильный документ проходит как обычно:

db.publishers.insertOne({ _id: 1, name: 'АСТ', country: 'Россия', founded: 1990 })
{ acknowledged: true, insertedId: 1 }

А вот документ без страны сервер отвергает:

db.publishers.insertOne({ _id: 2, name: 'Питер', founded: 1991 })
Uncaught:
MongoServerError: Document failed validation
Additional information: {
  failingDocumentId: 2,
  details: {
    operatorName: '$jsonSchema',
    schemaRulesNotSatisfied: [
      {
        operatorName: 'required',
        specifiedAs: { required: [ 'name', 'country' ] },
        missingProperties: [ 'country' ]
      }
    ]
  }
}

Ответ читается сверху вниз: валидация не пройдена, документ с номером 2, нарушено правило required, не хватает поля country. Если нарушений несколько, сервер перечислит их все:

db.publishers.insertOne({ _id: 3, name: 'Х', country: 'Франция' })
Uncaught:
MongoServerError: Document failed validation
Additional information: {
  failingDocumentId: 3,
  details: {
    operatorName: '$jsonSchema',
    schemaRulesNotSatisfied: [
      {
        operatorName: 'properties',
        propertiesNotSatisfied: [
          {
            propertyName: 'name',
            details: [
              {
                operatorName: 'minLength',
                specifiedAs: { minLength: 2 },
                reason: 'specified string length was not satisfied',
                consideredValue: 'Х'
              }
            ]
          },
          {
            propertyName: 'country',
            details: [
              {
                operatorName: 'enum',
                specifiedAs: { enum: [ 'Россия', 'США', 'Великобритания' ] },
                reason: 'value was not found in enum',
                consideredValue: 'Франция'
              }
            ]
          }
        ]
      }
    ]
  }
}

Проверка работает и при обновлении. Попробуем записать год основания словами:

db.publishers.updateOne({ _id: 1 }, { $set: { founded: 'девяностые' } })
Uncaught:
MongoServerError: Plan executor error during update :: caused by :: Document failed validation
Additional information: {
  failingDocumentId: 1,
  details: {
    operatorName: '$jsonSchema',
    schemaRulesNotSatisfied: [
      {
        operatorName: 'properties',
        propertiesNotSatisfied: [
          {
            propertyName: 'founded',
            details: [
              {
                operatorName: 'bsonType',
                specifiedAs: { bsonType: 'int' },
                reason: 'type did not match',
                consideredValue: 'девяностые',
                consideredType: 'string'
              }
            ]
          }
        ]
      }
    ]
  }
}

Одна тонкость с числами. mongosh записывает целое число вроде 1990 как int, а дробное вроде 1990.5 как double. Если поле может быть любым числом, пиши bsonType: ['int', 'double'].

урок из курса

Урок из курса «MongoDB с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.

Оплата пока не подключена: премиум и курсы открываются промокодом.