Document Modeling 2: Schema Pattern'leri, Schema Validation ve Şema Evrimi
MongoDB şemasız değil, esnek şemalıdır: computed, subset, bucket, polymorphic ve archive gibi pattern'ler modeli okuma ihtiyacına göre şekillendirir, $jsonSchema validation da veri kalitesini veritabanında korur; 9.0'ın constraint seviyesi her document'in kurala uyduğunu garanti eder. Gerçek çıktılarla kitapta yorum özeti, son 3 yorum, saatlik bucket, tek collection'da üç baskı türü, schemaVersion ile migration, moderate ve strict farkı ve constraint'e geçiş.
Sürüm notu: Bu yazıdaki örnekler MongoDB 9.0.8 Community Server ve mongosh 2.6 ile çalıştırılmıştır. Pattern örnekleri MongoDB 8.0.38'de de aynı çıktıyı verdi; validation tarafındaki 8.0 farkları ilgili bölümde.
Önce Tahmin Et
Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) yorum collection'ında yıllar önce başka bir uygulamanın yazdığı bir yorum var ve puanı sayı değil, "5" string'i. Ekip reviews'a bir validator ekliyor: rating 1 ile 5 arasında bir int olmalı. Validator önce moderate, sonra strict seviyesinde kuruluyor ve her seferinde eski yoruma bir metin ekleniyor:
for (const validationLevel of ["moderate", "strict"]) {
bookstore.runCommand({ collMod: "reviews", validator: { $jsonSchema: reviewSchema }, validationLevel, validationAction: "error" });
tryWrite(`${validationLevel.padEnd(8)} update legacy review`, () =>
bookstore.reviews.updateOne({ "customer.customerId": 39, bookId: 26 }, { $set: { body: `A classic (${validationLevel}).` } }));
}
Kuralı ihlal eden bir document varken validator eklenebilir mi? Eklenirse, eski yoruma yapılan bu küçük update iki seviyede de geçer mi?
moderate update legacy review: ok (modified 1)
strict update legacy review: MongoServerError: Plan executor error during update :: caused by :: Document failed validation
Validator eklenirken hiçbir hata olmadı; MongoDB mevcut document'leri o anda kontrol etmez. moderate seviyesinde kurala zaten uymayan document'lere yapılan update'ler denetlenmez, bu yüzden metin eklendi. strict seviyesinde her insert ve update denetlenir ve aynı update reddedildi, çünkü update'ten sonraki document de rating'i string olarak taşıyacaktı. Kuralı ihlal eden yorum collection'da duruyor ve kimse ona dokunmadıkça orada kalacak. MongoDB'ye "şemasız" demek bu yüzden yanlış; doğrusu "esnek şemalı": şema senin koyduğun kurallar kadar sıkı. Bu yazıda önce modeli okuma ihtiyacına göre şekillendiren schema pattern'lerine, sonra şemanın zaman içindeki değişimine ve son olarak validation'a, 9.0'ın yeni constraint seviyesi dahil, bakıyoruz.
Script'ler repodaki scripts klasöründe. Pattern örnekleri uygulama kullanıcısıyla, validation örnekleri admin olarak çalışıyor, çünkü 2. yazıda gördüğümüz gibi readWrite role'ü validator'ı değiştiren collMod komutunu kapsamıyor. Bazı script'ler veriyi kalıcı olarak değiştiriyor (kitaplara özet alanları, müşterilere yeni şema); lab'ı temizlemek için 02-load.sh yeterli.
Computed Pattern: Kitapta Yorum Özeti
Kitap kartı ortalama puanı ve yorum sayısını gösteriyor. Bunları her okumada reviews üzerinde bir aggregation ile hesaplamak, en sık okunan sayfaya en pahalı işi koymak demek. Computed pattern hesaplamayı yazma anına taşır: sonuç kitapta durur, yeni yorum geldiğinde güncellenir.
// 05 - computed pattern: kitapta yorum özeti
db.reviews.deleteMany({ "customer.customerId": 40, bookId: 26 });
db.books.updateMany({}, { $unset: { rating: "" } });
// İlk dolum: reviews'dan bir kez hesapla
db.reviews.aggregate([
{ $group: { _id: "$bookId", count: { $sum: 1 }, sum: { $sum: "$rating" } } }
]).forEach(r => db.books.updateOne(
{ _id: r._id },
{ $set: { rating: { count: r.count, sum: r.sum, average: Math.round(r.sum / r.count * 100) / 100 } } }
));
printjson(db.books.findOne({ _id: 26 }, { _id: 0, title: 1, rating: 1 }));
// Yeni yorum: yorum kendi collection'ına, özet kitaba (pipeline update)
db.reviews.insertOne({ bookId: 26, customer: { customerId: 40, name: "Linus Torvalds" }, rating: 5,
body: "Re-read it after twenty years.", reviewedAt: ISODate("2026-06-30T10:00:00Z") });
db.books.updateOne({ _id: 26 }, [
{ $set: { "rating.count": { $add: ["$rating.count", 1] }, "rating.sum": { $add: ["$rating.sum", 5] } } },
{ $set: { "rating.average": { $round: [{ $divide: ["$rating.sum", "$rating.count"] }, 2] } } }
]);
printjson(db.books.findOne({ _id: 26 }, { _id: 0, rating: 1 }));
print("books with rating:", db.books.countDocuments({ rating: { $exists: true } }));
{
title: 'Dune',
rating: {
count: 5,
sum: 22,
average: 4.4
}
}
{
rating: {
count: 6,
sum: 27,
average: 4.5
}
}
books with rating: 58
Özette ortalamanın yanında toplam ve sayı da duruyor; ortalamayı yalnız ortalamadan güncelleyemezsin. Güncelleme bir aggregation pipeline update'i: köşeli parantez içindeki stage'ler document'in mevcut değerlerini ($rating.count) okuyabiliyor, bu yüzden sayı, toplam ve yeni ortalama tek atomik update'te hesaplanıyor. Bedeli şu: yorum ve özet iki ayrı document'e yazılıyor. Yorum yazılıp özet güncellenemezse özet bir yorum geride kalır. Kitap kartındaki bir ortalama için bu çoğu zaman kabul edilebilir ve özet periyodik olarak reviews'dan yeniden hesaplanarak düzeltilir; kabul edilemiyorsa iki yazma bir transaction'a (10. yazı) girer. Puan gibi sık değişen ve kesinliği kritik olmayan değerler için MongoDB dokümanı bir adım ötesini de anlatıyor: approximation pattern, yani özeti her yazmada değil, örneğin her onuncu yazmada güncellemek.
Subset ve Bucket: Okunanı Küçük Tutmak
4. yazıda yorumları kitaba gömmenin neden kötü olduğunu ölçtük. Ama kitap sayfası son birkaç yorumu her açılışta gösteriyor ve bunun için reviews'a ikinci bir sorgu atmak istemiyoruz. Subset pattern ikisinin ortası: yorumların tamamı reviews'da, yalnız son üçünün küçük bir kopyası kitapta.
// 05 - subset pattern: kitap sayfası için son 3 yorumun kopyası
db.books.updateOne({ _id: 26 }, { $unset: { recentReviews: "" } });
const latest = db.reviews.find({ bookId: 26 }, { _id: 1, rating: 1, body: 1, "customer.name": 1, reviewedAt: 1 })
.sort({ reviewedAt: -1 }).limit(3).toArray();
db.books.updateOne({ _id: 26 }, { $set: { recentReviews: latest } });
// Yeni yorum geldiğinde: ekle, sırala, ilk 3'ü tut
const review = { _id: new ObjectId(), rating: 4, body: "Slow start, great ending.",
customer: { name: "Ada Lovelace" }, reviewedAt: ISODate("2026-06-30T12:00:00Z") };
db.books.updateOne({ _id: 26 }, {
$push: { recentReviews: { $each: [review], $sort: { reviewedAt: -1 }, $slice: 3 } }
});
printjson(db.books.findOne({ _id: 26 }).recentReviews.map(r => ({ by: r.customer.name, at: r.reviewedAt })));
print("book size:", bsonsize(db.books.findOne({ _id: 26 })), "bytes");
[
{
by: 'Ada Lovelace',
at: ISODate('2026-06-30T12:00:00.000Z')
},
{
by: 'Linus Torvalds',
at: ISODate('2026-06-30T10:00:00.000Z')
},
{
by: 'Adele Goldberg',
at: ISODate('2026-05-04T01:49:15.000Z')
}
]
book size: 808 bytes
$push ile $each, $sort ve $slice birlikte kullanılınca yeni yorum eklenir, array tarihe göre sıralanır ve yalnız ilk üç element kalır; array hiçbir zaman üçü geçmez. Kitap, özet ve üç yorumla 808 byte. Gerçek uygulamada yeni yorum önce reviews'a yazılır, aynı _id ile kopyası kitaba eklenir; script yalnız ikinci adımı gösteriyor. "Bütün yorumları göster" bağlantısı ise reviews üzerinde sayfalı bir sorgu. Kopyanın bedeli 4. yazıdaki gibi: bir yorum silinir ya da düzenlenirse kitaptaki kopyası da güncellenmeli.
Görüntülenme kayıtları ise 4. yazıdaki one-to-squillions durumu: her görüntülenmeyi ayrı bir document olarak yazmak milyonlarca küçük document ve aynı ölçüde index girdisi demek. Bucket pattern kayıtları bir zaman aralığına göre gruplar; burada her kitap için saat başına bir document:
// 05 - bucket pattern: kitap görüntülenmeleri saatlik document'lerde
db.book_views.drop();
const start = ISODate("2026-06-30T09:00:00Z").getTime();
for (let i = 0; i < 1000; i++) {
const at = new Date(start + i * 11 * 1000); // her 11 saniyede bir görüntülenme
const hour = new Date(Math.floor(at.getTime() / 3600000) * 3600000);
db.book_views.updateOne(
{ bookId: 26, hour },
{ $inc: { count: 1, [`byCountry.${["TR", "GB", "DE"][i % 3]}`]: 1 } },
{ upsert: true }
);
}
print("documents:", db.book_views.countDocuments());
db.book_views.find().sort({ hour: 1 }).forEach(b =>
print(b.hour.toISOString(), "count", b.count, "TR", b.byCountry.TR, "GB", b.byCountry.GB, "DE", b.byCountry.DE));
documents: 4
2026-06-30T09:00:00.000Z count 328 TR 110 GB 109 DE 109
2026-06-30T10:00:00.000Z count 327 TR 109 GB 109 DE 109
2026-06-30T11:00:00.000Z count 327 TR 109 GB 109 DE 109
2026-06-30T12:00:00.000Z count 18 TR 6 GB 6 DE 6
Bin görüntülenme dört document'e indi. Her görüntülenme bir upsert: o saatin document'i yoksa oluşur, varsa sayaçlar $inc ile artar. "Son 24 saatin grafiği" artık 24 document okumak. Bucket'ın boyutu workload'a göre seçilir: saatlik çok kaba ise dakikalık, her bucket'ta ham olayları da tutmak istiyorsan bir üst sınırla (örneğin bucket başına 1000 olay) yeni bucket açan bir yapı. Ölçüm ve sensör verisi için MongoDB'nin time series collection'ları bu gruplamayı kendisi yapıyor; 9.0'daki değişiklikleri 15. yazıda. Raporlama için bu bucket'lar üzerinde aggregation 7. ve 8. yazılarda.
Polymorphic ve Attribute Pattern: Basılı, E-Kitap ve Sesli Kitap
Bir kitabın farklı baskıları ortak alanlar (kitap, format, fiyat) ve formata özgü alanlar taşıyor: basılı baskının cildi ve ağırlığı, e-kitabın dosya formatları ve DRM (kopyalamayı kısıtlayan dijital hak yönetimi) durumu, sesli kitabın seslendireni ve süresi. İlişkisel modelde bu, boş kolonlarla dolu bir tablo ya da format başına ayrı tablolar demek. Polymorphic pattern'de hepsi aynı collection'da, her document kendi alanlarıyla:
// 05 - polymorphic pattern: basılı, e-kitap ve sesli kitap baskıları tek collection'da
db.editions.drop();
db.editions.insertMany([
{ _id: "26-print", bookId: 26, format: "print", price: Decimal128("390.50"),
binding: "paperback", pageCount: 513, weightGrams: 520 },
{ _id: "26-ebook", bookId: 26, format: "ebook", price: Decimal128("210.00"),
fileFormats: ["epub", "pdf"], drm: false },
{ _id: "26-audio", bookId: 26, format: "audiobook", price: Decimal128("320.00"),
narrator: "Grace Hopper", durationMinutes: 1263 }
]);
// Ortak alanlarla tek sorgu; formata özgü alanlar yalnız ilgili document'te
printjson(db.editions.find({ bookId: 26 }, { _id: 0, format: 1, price: 1 }).sort({ price: 1 }).toArray());
print("ebooks without DRM:", db.editions.countDocuments({ format: "ebook", drm: false }));
[
{
format: 'ebook',
price: Decimal128('210.00')
},
{
format: 'audiobook',
price: Decimal128('320.00')
},
{
format: 'print',
price: Decimal128('390.50')
}
]
ebooks without DRM: 1
"Bu kitabın baskıları, ucuzdan pahalıya" tek sorgu ve tek index. format alanı bir discriminator; uygulama hangi alanları bekleyeceğini ondan biliyor. .NET tarafında bunun karşılığı, driver'ın _t alanıyla yaptığı polymorphic serialization ve 9. yazıda göreceğiz. Formata özgü alanlarda arama yapmak istediğinde bir sorun çıkıyor: her alan için ayrı index gerekir ve yeni bir format yeni index'ler demek. Attribute pattern bu alanları k/v çiftlerinden oluşan tek bir array'e taşır ve tek bir compound index'le hepsini aranabilir yapar:
// Attribute pattern: aranabilir özellikler tek bir k/v array'inde, tek index'le
db.editions.updateMany({}, [{ $set: { attributes: {
$filter: {
input: { $objectToArray: "$$ROOT" },
cond: { $in: ["$$this.k", ["binding", "pageCount", "drm", "narrator", "durationMinutes"]] }
}
} } }]);
db.editions.createIndex({ "attributes.k": 1, "attributes.v": 1 });
const stats = db.editions.find({ attributes: { $elemMatch: { k: "narrator", v: "Grace Hopper" } } })
.explain("executionStats");
print("narrator query:", stats.queryPlanner.winningPlan.inputStage?.stage ?? stats.queryPlanner.winningPlan.stage,
"keysExamined", stats.executionStats.totalKeysExamined, "returned", stats.executionStats.nReturned);
printjson(db.editions.findOne({ _id: "26-audio" }, { _id: 0, attributes: 1 }));
narrator query: IXSCAN keysExamined 1 returned 1
{
attributes: [
{
k: 'narrator',
v: 'Grace Hopper'
},
{
k: 'durationMinutes',
v: 1263
}
]
}
Sorgu index'ten tek bir girdi okuyup sonucu buldu (IXSCAN, yani index taraması). 3. yazıdaki $elemMatch kuralı burada hayati: k ve v koşulları aynı element'te sağlanmalı, yoksa "seslendireni Grace Hopper" yerine "herhangi bir özelliği narrator, herhangi bir değeri Grace Hopper" ararsın. Bu yapı ilişkisel dünyadaki EAV (entity-attribute-value; her özelliği ayrı bir satır olarak saklayan model) yapısına benziyor ve onun sorunlarını kısmen taşıyor: v'nin tipi özellikten özelliğe değişiyor ve aralık sorguları tip karışıklığına açık. EAV'nin ilişkisel taraftaki sorunları Data Modeling ve Normalizasyon serisinin "Modeling Anti-Pattern'leri" yazısında. Attribute pattern'i yalnız gerçekten aranan ve sayısı öngörülemeyen özellikler için kullan; sabit, az sayıdaki alanlar normal alan olarak kalsın.
Archive ve Single Collection
Kapanmış eski siparişler nadiren okunuyor ama aktif siparişlerle aynı collection'da, aynı index'lerde ve aynı cache'te yer kaplıyor. Archive pattern onları ayrı bir collection'a (ya da daha ucuz bir depolamaya) taşır. 2025-07-01'den önce teslim edilmiş ya da iptal edilmiş siparişleri orders_archive'e taşıyalım:
// 05 - archive pattern: 2025-07-01 öncesi kapanmış siparişler orders_archive'e taşınır
db.orders_archive.drop();
const cutoff = ISODate("2025-07-01T00:00:00Z");
const filter = { status: { $in: ["Delivered", "Cancelled"] }, orderedAt: { $lt: cutoff } };
db.orders.aggregate([
{ $match: filter },
{ $merge: { into: "orders_archive", whenMatched: "keepExisting", whenNotMatched: "insert" } }
]);
const archivedIds = db.orders_archive.distinct("_id");
const r = db.orders.deleteMany({ _id: { $in: archivedIds }, ...filter });
print("archived:", db.orders_archive.countDocuments(), " deleted:", r.deletedCount, " orders left:", db.orders.countDocuments());
// Geri al: sonraki yazılar için siparişleri geri taşı
db.orders_archive.aggregate([{ $merge: { into: "orders", whenMatched: "keepExisting", whenNotMatched: "insert" } }]);
db.orders_archive.drop();
print("orders after undo:", db.orders.countDocuments());
archived: 109 deleted: 109 orders left: 270
orders after undo: 379
İki adım bilinçli olarak bu sırada: önce kopyala, sonra yalnız arşivde gerçekten bulunanları sil. İşlem yarıda kesilirse en kötü durumda bir sipariş iki yerde birden durur ve script yeniden çalıştırıldığında keepExisting sayesinde kopya tekrarlanmaz; hiçbir sipariş kaybolmaz. $merge aggregation'ın sonucunu bir collection'a yazan stage, ayrıntısı 8. yazıda. Script sonraki yazılar için siparişleri geri taşıyor, ikinci çıktı satırı bu. Arşiv sorgusu ayrı bir yol: "siparişlerim" ekranı son bir yılı orders'tan, daha eskisini istek üzerine orders_archive'den okur.
Single collection pattern ise ters yönde ilerler: birlikte okunan farklı türdeki document'leri tek collection'da, ortak bir anahtar ve bir tür alanıyla tutar. Örneğin bir sipariş ve onun kargo olayları aynı collection'da orderId ve type alanlarıyla durabilir; tek bir index taraması siparişi ve bütün olaylarını sırayla getirir. Bookstore'da bunu kullanmadık; okuma ihtiyacı gerçekten "farklı türleri birlikte ve sıralı oku" olduğunda ve gömme sınırları zorladığında değerlendirilir.
Schema Versioning: Şemanın Zaman İçinde Değişmesi
Müşterinin adres bilgisi bugün document'in kökünde: city ve country. Yeni gereksinim birden çok adres ve posta kodu getiriyor; ilk adım bu alanları bir address alt document'ine taşımak. İlişkisel veritabanında bu bir migration ve tek bir deploy anı. MongoDB'de iki şekil bir süre birlikte yaşayabilir; schema versioning pattern bunu açık hâle getirir: her document hangi şema sürümünde olduğunu bir alanla söyler.
// Yeni kod v2 yazar: yeni ve güncellenen müşteriler
db.customers.updateOne({ _id: 3 }, [
{ $set: { schemaVersion: 2, address: { city: "$city", country: "$country" } } },
{ $unset: ["city", "country"] }
]);
// Okuma iki şekli birlikte destekler
printjson(db.customers.aggregate([
{ $match: { _id: { $in: [2, 3] } } },
{ $project: {
_id: 1,
schemaVersion: { $ifNull: ["$schemaVersion", 1] },
city: { $ifNull: ["$address.city", "$city"] }
} }
]).toArray());
[
{
_id: 2,
schemaVersion: 1,
city: 'Berlin'
},
{
_id: 3,
schemaVersion: 2,
city: 'Istanbul'
}
]
Alanı olmayan document v1 sayılıyor; yeni kod müşteriyi güncellerken v2 yazıyor, okurken iki şekli de anlıyor. .NET tarafında bu, okuma modelinin iki şekli de karşılaması ya da serialization katmanında küçük bir dönüştürücü demek. Bu sayede şema değişikliği bir bakım penceresi gerektirmiyor. Eski document'ler ya güncellendikçe kendiliğinden ya da arka planda küçük batch'lerle taşınıyor:
// Arka planda migration: küçük batch'ler, kaldığı yerden devam edebilir
let migrated = 0;
while (true) {
const ids = db.customers.find({ schemaVersion: { $exists: false } }, { _id: 1 }).limit(15).toArray().map(c => c._id);
if (ids.length === 0) break;
const r = db.customers.updateMany({ _id: { $in: ids }, schemaVersion: { $exists: false } }, [
{ $set: { schemaVersion: 2, address: { city: "$city", country: "$country" } } },
{ $unset: ["city", "country"] }
]);
migrated += r.modifiedCount;
print("batch:", r.modifiedCount);
}
print("migrated:", migrated, " v1 left:", db.customers.countDocuments({ schemaVersion: { $exists: false } }));
printjson(db.customers.findOne({ _id: 2 }, { _id: 0, firstName: 1, schemaVersion: 1, address: 1 }));
batch: 15
batch: 15
batch: 9
migrated: 39 v1 left: 0
{
firstName: 'Alan',
schemaVersion: 2,
address: {
city: 'Berlin',
country: 'DE'
}
}
Kırk müşteriden biri zaten v2'ydi, kalan 39'u üç batch'te taşındı. Her batch'in filtresi schemaVersion: { $exists: false } koşulunu tekrar ediyor; bu sayede migration yarıda kesilirse kaldığı yerden devam ediyor ve aynı document iki kez taşınmıyor. Production'da batch boyutu binlerle ölçülür ve batch'ler arasına kısa bir bekleme konur ki migration canlı trafiği ve replication'ı boğmasın. Bütün document'ler v2 olunca okuma kodundaki v1 desteği bir sonraki sürümde kaldırılır; v1 dalını sonsuza kadar taşımak, yıllar sonra kimsenin anlamadığı bir $ifNull zinciri demek.
Schema Validation: $jsonSchema, validationLevel ve validationAction
Validation kuralı, collection'a collMod (ya da createCollection) ile eklenen bir sorgu ifadesi; en yaygın biçimi JSON Schema'nın (JSON document'lerinin yapısını tanımlayan standart) draft 4'üne dayanan $jsonSchema. MongoDB tipler için JSON Schema'nın type'ı yerine BSON (MongoDB'nin ikili document formatı) tiplerini bilen bsonType anahtarını ekliyor. "Önce Tahmin Et"teki validator şu:
const reviewSchema = {
bsonType: "object",
required: ["bookId", "customer", "rating", "reviewedAt"],
properties: {
bookId: { bsonType: "int" },
customer: {
bsonType: "object",
required: ["customerId", "name"],
properties: { customerId: { bsonType: "int" }, name: { bsonType: "string", minLength: 1 } }
},
rating: { bsonType: "int", minimum: 1, maximum: 5 },
body: { bsonType: "string", maxLength: 1000 },
reviewedAt: { bsonType: "date" },
helpfulCount: { bsonType: "int", minimum: 0 },
helpfulVoters: { bsonType: "array", items: { bsonType: "int" } }
}
};
Kurala uymayan bir yorum gelince sunucu ne söylüyor:
const e = tryWrite("insert rating 6", () => bookstore.reviews.insertOne({
bookId: 26, customer: { customerId: 38, name: "Margaret Hamilton" }, rating: 6, reviewedAt: new Date() }));
printjson(e.errInfo.details.schemaRulesNotSatisfied);
print("invalid documents:", bookstore.reviews.countDocuments({ $nor: [{ $jsonSchema: reviewSchema }] }));
insert rating 6: MongoServerError: Document failed validation
[
{
operatorName: 'properties',
propertiesNotSatisfied: [
{
propertyName: 'rating',
details: [
{
operatorName: 'maximum',
specifiedAs: {
maximum: 5
},
reason: 'comparison failed',
consideredValue: 6
}
]
}
]
}
]
invalid documents: 1
Hata errInfo.details içinde hangi alanın hangi kuralı ihlal ettiğini söylüyor; uygulama bunu log'a yazmalı, ama doküman bu metnin insanlar için olduğunu ve script'lerin ona dayanmaması gerektiğini belirtiyor. Son satır mevcut veri kalitesini ölçmenin yolu: { $nor: [{ $jsonSchema: reviewSchema }] } kurala uymayan document'leri bulur; tek sonuç "Önce Tahmin Et"teki eski yorum. Validator'ı eklemeden önce bu sorguyu çalıştırmak, kaç document'i temizlemen gerektiğini baştan gösterir.
İki ayar validator'ın davranışını belirliyor. validationLevel: strict (varsayılan) her insert ve update'i, moderate insert'leri ve yalnız şu an kurala uyan document'lerin update'lerini denetler; off validation'ı kapatır. validationAction: error (varsayılan) yazmayı reddeder, warn yazmaya izin verip ihlali log'a yazar, 8.1 ile gelen errorAndLog hem reddeder hem log'a yazar. moderate ve warn, mevcut kirli veriye validator eklerken işe yarayan geçiş ayarları: önce warn ile ne kadar yazmanın kuralı ihlal ettiğini log'dan görürsün, veriyi temizler, sonra strict ve error'a geçersin.
9.0: constraint Seviyesi
strict yeni yazmaları denetler ama collection'daki eski ihlalleri bilmez; "Önce Tahmin Et"teki yorum strict'te de collection'da kaldı. MongoDB 9.0 bunun için constraint seviyesini getirdi: seviyeye geçerken MongoDB collection'ın tamamını tarar ve kurala uymayan tek bir document bile varsa geçişi reddeder; geçtikten sonra collection'daki her document'in kurala uyduğu garanti. Mevcut bir collection için önce prepareConstraintValidationLevel ile hazırlık yapılıyor, sonra seviye değiştiriliyor:
// 05 - MongoDB 9.0: constraint validation level
const bookstore = db.getSiblingDB("bookstore");
tryCommand("prepare", () => bookstore.runCommand({ collMod: "reviews", prepareConstraintValidationLevel: true }));
tryCommand("constraint (1)", () => bookstore.runCommand({ collMod: "reviews", validationLevel: "constraint", validationAction: "error" }));
// Kuralı ihlal eden eski document'i düzelt (string -> int)
const fixed = bookstore.reviews.updateMany({ rating: { $type: "string" } }, [{ $set: { rating: { $toInt: "$rating" } } }]);
print("fixed:", fixed.modifiedCount);
tryCommand("constraint (2)", () => bookstore.runCommand({ collMod: "reviews", validationLevel: "constraint", validationAction: "error" }));
tryCommand("bypassDocumentValidation", () => bookstore.runCommand({
insert: "reviews", bypassDocumentValidation: true,
documents: [{ bookId: 26, customer: { customerId: 38, name: "Margaret Hamilton" }, rating: 6, reviewedAt: new Date() }]
}));
tryCommand("change validator", () => bookstore.runCommand({ collMod: "reviews", validator: { $jsonSchema: { bsonType: "object" } } }));
const options = bookstore.getCollectionInfos({ name: "reviews" })[0].options;
print("level:", options.validationLevel, " action:", options.validationAction);
prepare: ok
constraint (1): MongoServerError: Cannot upgrade validationLevel to 'constraint': the collection contains documents that do not satisfy the validator. First offending document _id: ObjectId('6af85e4c5a7edf5858284d0c') Run db.reviews.find({"$nor": [{ $jsonSchema: { ... } }]}) to find non-compliant documents.
fixed: 1
constraint (2): ok
bypassDocumentValidation: inserted 0, writeError code 2: bypassDocumentValidation is not permitted with 'constraint' validationLevel
change validator: MongoServerError: Failed to set validation options :: caused by :: Validator cannot be changed when Validation level is 'constraint'
level: constraint action: error
İlk geçiş reddedildi ve hata, ihlal eden ilk document'in _id'sini ve onları bulacak sorguyu (burada kısalttık) verdi. String puanı $toInt ile düzelten bir pipeline update'ten sonra geçiş başarılı oldu. constraint seviyesinin iki sonucu daha var: normalde yetkili bir kullanıcının validation'ı atlamasını sağlayan bypassDocumentValidation artık reddediliyor ve validator değiştirilemiyor; kuralı değiştirmek için önce strict'e inmek, kuralı değiştirmek, sonra yeniden constraint'e çıkmak gerekiyor. Doküman, geçişteki taramanın büyük collection'larda pahalı olabileceğini de belirtiyor. Script'in sonunda reviews constraint seviyesinde kalıyor; 05-validation.js tekrar çalıştırıldığında önce strict'e iniyor.
MongoDB 8.0'da farkı: constraint seviyesi ve prepareConstraintValidationLevel yok, errorAndLog da 8.1 ile geldiği için 8.0'da yok. 8.0.38'de üçü de şu hatalarla reddedildi: Enumeration value 'constraint' for field 'collMod.validationLevel' is not a valid value, aynısı errorAndLog için ve BSON field 'collMod.prepareConstraintValidationLevel' is an unknown field. moderate ve strict örnekleri 8.0'da aynı sonucu verdi; yalnız strict hatasının başında 9.0'daki Plan executor error during update :: caused by :: öneki yok.
Anti-Pattern'ler
MongoDB dokümanının şema tasarımı anti-pattern listesinin büyük kısmını bu iki yazıda zaten gördük; kısaca topluca bakalım:
- Sınırsız büyüyen array: 4. yazıda 110.000 yorumda 16 MB sınırına çarptık. Çözüm referans, gerekiyorsa subset.
- Çok sayıda collection: her kategori, her müşteri ya da her gün için ayrı collection açmak. Her collection ve her index ayrı dosyalar, ayrı cache alanı ve yönetim yükü demek; bunlar bir alanla ayrılan tek bir collection olmalı.
- Şişkin document: sık okunan document'te nadiren okunan büyük alanlar (uzun açıklama, eski fiyat geçmişi). Kitap kartı her açıldığında bu alanlar da cache'e taşınır; ayrı bir document'e taşı.
- Index'siz case-insensitive arama: 3. yazıdaki
$regexileiseçeneği her document'i taradı. Doğru yol collation'lı index, 6. yazıda. - Gereksiz index: her index her yazmada güncellenir ve bellekte yer kaplar. Hiç kullanılmayan ya da başka bir index'in ön eki olan index'ler silinmeli; 6. yazıda nasıl bulunacağını göreceğiz.
- Birlikte okunan veriyi ayırmak: 4. yazının sonundaki her okumada
$lookupyapan ilişkisel kopya.
Üretimde Dikkat
Validator'ı collection'la birlikte oluştur. Validation sonradan eklendiğinde mevcut ihlaller kalır ve onları bulup temizlemek ayrı bir iş olur. Yeni bir collection'ı createCollection ile validator'ıyla birlikte açmak, 9.0'da gerekiyorsa doğrudan constraint seviyesiyle, en ucuz yol. Validator'ları uygulama kodundaki migration'larla aynı yerde, sürüm kontrolünde tut.
Validation'ın maliyetini bil. Her insert ve update kuralı değerlendirir; makul bir $jsonSchema için bu maliyet küçük, ama çok büyük ve iç içe şemalarda ölçülmeli. constraint'e geçişteki tam tarama büyük collection'larda yoğun I/O (disk okuma ve yazma) demek; yoğun olmayan bir saatte yap.
Computed ve subset alanlarını yeniden üretilebilir tut. Kitaptaki yorum özeti ve son yorumlar, reviews'dan her an yeniden hesaplanabilmeli. Böyle bir script olduğunda bir hata ya da yarım kalmış yazma kalıcı bir tutarsızlık olmaktan çıkar, düzeltilebilir bir sapma olur.
Sık Düşülen Tuzaklar
Validation'ı hiç kullanmayıp veri kalitesini uygulama koduna bırakmak. Uygulama kodu tek yazıcı olduğu sürece işe yarar. Sonra bir import script'i, bir admin aracı ya da başka bir servis aynı collection'a yazar ve "Önce Tahmin Et"teki string puan oluşur. Validator, kimin yazdığından bağımsız tek kontrol noktası.
Validator ekledim diye eski veriyi temiz sanmak. strict dahil hiçbir seviye mevcut document'leri geriye dönük kontrol etmez; constraint hariç. Validator ekledikten sonra $nor ile $jsonSchema sorgusunu çalıştırıp ihlal sayısını görmeden işi bitmiş sayma.
Schema versioning'i yarım bırakmak. schemaVersion alanını ekleyip migration'ı hiç bitirmemek, okuma kodunda sonsuza kadar iki dal taşımak demek. Her şema sürümünün bir bitiş planı olmalı: migration tamamlandığında eski dal ve gerekiyorsa eski şemayı kabul eden validator gevşekliği kaldırılır.
Küçük Bir Deneme
★☆☆ Bütün kitapların yorum özetini reviews'dan yeniden hesaplayıp kitaplardaki rating alanıyla karşılaştıran bir kontrol script'i yaz; farklı çıkan kitap varsa listele.
★★☆ editions için oneOf kullanan bir $jsonSchema yaz: ortak alanlar her formatta zorunlu, print için pageCount, ebook için fileFormats, audiobook için narrator zorunlu olsun. Narrator'ı olmayan bir sesli kitap ekleyip hatanın details kısmını incele.
★★★ customers için v2 şemasını validator'a çevir ve migration sürerken uygulamanın iki şekli de yazabilmesi için validator'ı anyOf ile iki şekli kabul edecek biçimde kur. Migration bitince validator'ı yalnız v2'yi kabul edecek şekilde daraltıp constraint'e geç; geçişin neden ancak migration tamamlandıktan sonra başarılı olduğunu açıkla.
Model artık kararlarıyla, pattern'leriyle ve kurallarıyla yerinde. Bir sonraki yazıda bu modeli hızlı okumanın yoluna geçiyoruz: compound index ve ESR (Equality, Sort, Range; compound index'te alan sırasını belirleyen kural), multikey, partial, TTL (belirli bir süre sonra document'i silen) ve collation'lı index'ler ve explain çıktısını okumak.