در این مقاله قصد داریم تا برخی از ویژگی‌های جالب مربوط به Schema در Mongoose را مورد بحث قرار می‌دهیم که به ما کمک می‌کند تا کدهای سازمان‌یافته‌تر و قابل نگه‌داری‌تری بنویسیم. این مقاله مخصوصا برای کسانی که به ساخت برنامه‌های NodeJS با استفاده از Mongoose ORM علاقه دارند، می‌تواند بسیار مفید باشد.

Mongoose Schema چیست؟

Schema در Mongoose روشی ساختاریافته برای مدل‌سازی داده‌ها در پایگاه داده MongoDB ارائه می‌دهد و این امکان را برای ما فراهم می‌کند تا خصوصیات و رفتار داکیومنت‌ها را تعریف کنیم. Schemaها به عنوان طرحی برای داکیومنتی که در پایگاه داده ذخیره می‌شود، عمل می‌کنند. آن‌ها توسعه‌دهندگان را قادر می‌سازند تا داده‌های یکپارچه داشته باشند و با MongoDB به شیوه‌ای شهودی و سازماندهی شده کار کنند.

در یک کالکشن MongoDB، یک Schema فیلدهای documentها، data typeها، قوانین اعتبارسنجی، مقادیر پیش‌فرض، محدودیت‌ها و موارد دیگر را مشخص می‌کند.

از نظر برنامه نویسی، Mongoose schema یک آبجکت جاوااسکریپت است. در واقع، این یک نمونه از یک کلاس داخلی به نام Schema در ماژول mongoose می‌باشد. به همین دلیل می‌توانیم متدهای بیشتری را به نمونه اولیه آن اضافه کنیم. این موضوع به ما کمک می‌کند تا بسیاری از ویژگی‌ها مانند middleware، متدها، statics و غیره را پیاده‌سازی نماییم. در ادامه با برخی از آن‌ها آشنا خواهیم شد.

ویژگی‌هایی که در ادامه مقاله یاد می‌گیریم چگونه باید آن‌ها را پیاده‌سازی کنیم:

Discriminator

Discriminator ویژگی است که به ما کمک می‌کند تا چندین مدل (subtype) ایجاد کنیم که از یک مدل پایه (parent) ارث‌بری می‌کنند. این امر با تعریف یک schema پایه و سپس extend کردن آن با فیلدهای اضافی خاص برای هر subtype یا هر child schema اتفاق می‌افتد.

همه داکیومنت‌ها، صرف نظر از مدل خاص آن‌ها، در یک کالکشن MongoDB ذخیره می‌شوند. این کار داده‌های ما را در یک مجموعه واحد سازماندهی می‌کند و در عین حال امکان انجام کوئری و مدیریت داده‌های انعطاف‌پذیر را فراهم می‌آورد. همچنین، هر داکیومنت شامل یک فیلد خاص است که نوع مدل خاص خود را نشان می‌دهد و به Mongoose اجازه می‌دهد بین زیرگروه‌های مختلف تمایز قائل شود.

چگونه باید از discriminator استفاده کنیم؟

۱ – کار خود را با تعریف یک schema پایه شروع می‌کنیم که دارای فیلدهای مشترک بین subtypeها خواهد بود. سپس، یک مدل از آن می‌سازیم:

import mongoose from 'mongoose';

const baseSchema = new mongoose.Schema({
    name: { type: String, required: true },
}, { discriminatorKey: 'kind' }; // defaults to '__t');

const BaseModel = mongoose.model('Base', baseSchema);

۲ – subtypeهایی را ایجاد می‌کنیم که با تعریف discriminator برای هر یک، schema پایه را extend کند.

const catSchema = new mongoose.Schema({
    meow: { type: Boolean, default: true }
});
// subtype
const Cat = BaseModel.discriminator('Cat', catSchema);

const dogSchema = new mongoose.Schema({
    bark: { type: Boolean, default: true }
});
// subtype
const Dog = BaseModel.discriminator('Dog', dogSchema);

۳ – سپس می‌توانیم داکیومنت‌ها را به روش معمول بسازیم. همه داکیومنت‌ها در یک collection ذخیره می‌شوند، اما هر کدام بسته به subtype model خود، نوع خاص مربوط به خود را دارا می‌باشد.

const fluffy = await Cat.create({ name: 'Fluffy' });
const rover = await Dog.create({ name: 'Rover' });

بررسی موارد استفاده از Discriminator

فرض کنید ما در حال ساخت یک وب اپلیکیشن چند کاربره Ecommerce هستیم که سه نقش اصلی کاربر را در خود جای می‌دهد: ادمین، مشتری و فروشنده. هر یک از این نقش‌ها وظیفه مهمی در اکوسیستم خرید آنلاین برعهده دارد.

اگر بخواهیم برای هر نقش یک کلاس جدا بسازیم، متوجه می‌شویم که هر سه آن‌ها فیلدها و متدهای مشترکی باهم دارند. بنابراین، ممکن است تصمیم بگیریم که یک parent schema (کاربر) و سایر schemaهای child (مشتری، فروشنده، ادمین) را بسازیم که از آن ارث‌بری می‌کنند.

برای رسیدن به این هدف می‌توانیم از discriminator استفاده کنیم.

کد زیر را در فایل user.model.js می‌نویسیم:

import mongoose from "mongoose";

const userSchema = mongoose.Schema(
  {
    name: String,
    profilePic: String,
    email: String,
    password: String,
    birthDate: Date,
    accountAcctivated: { type: Boolean, default: false },
  },
  {
    timestamps: true,
    discriminatorKey: "role",
  }
);

const User = mongoose.model("User", userSchema);
export default User;

اکنون ما مدل پایه (User) را داریم که subtypeهای دیگر از آن ارث‌بری می‌کنند. در این parent schema، فیلدهای مشترکی را تعریف می‌کنیم که همه کاربران، صرف نظر از نقش آن‌ها به اشتراک می‌گذارند.

در فایل client.model.js:

import mongoose from "mongoose";
import User from "./user.model.js";

const clientSchema = mongoose.Schema(
  {
    products: Array,
    address: String,
    phone: String,
  }
);

const Client = User.discriminator("Client", clientSchema);
export default Client;

سپس، در فایل  seller.model.js :

import mongoose from "mongoose";
import User from "./user.model.js";

export const sellerSchema = mongoose.Schema(
  {
    rating: Number,
    businessType: { type: String, enum: ["individual", "corporation"] },
  }
);

const Seller = User.discriminator("Seller", sellerSchema);
export default Seller;

درنهایت، در فایل admin.model.js:

import mongoose from "mongoose";
import User from "./user.model.js";

export const adminSchema = mongoose.Schema(
  {
    permissions: Array,
    assignedTasks: Array,
    department: String,
  }
);

const Admin = User.discriminator("Admin", adminSchema);
export default Admin;

subtypeها یا childها Client، Seller و Admin خواهند بود. در هر subtype schema، ما باید هر گونه فیلد یا رفتار اضافی را فقط به این subtype اضافه کنیم. با ایجاد مدل child با استفاده از discriminator، تمامی فیلدها و متدهای مدل parent خود یعنی مدل User را ارث‌بری می‌کند.

کد قبلی یک کالکشن user در پایگاه داده ایجاد می‌کند که هر داکیومنت دارای یک فیلد role با مقادیر Client یا Seller یا Admin است. همه داکیومنت‌ها فیلدهای parent (user) را به اشتراک می‌گذارند و بسته به role هر داکیومنت، هر کدام یک فیلد اضافی دیگر نیز دارند.

اگرچه تمام داکیومنت‌ها در یک collection ذخیره می‌شوند، اما مدل‌ها در هنگام کدنویسی کاملاً از هم جدا هستند.

به عنوان مثال، اگر ما نیاز داریم همه مشتریان از کالکشن User را بازیابی کنیم، باید Client.find({}) را بنویسیم. این عبارت از discriminator key برای یافتن تمام داکیومنت‌هایی که role آن‌ها Client است، استفاده می‌کند. به این ترتیب، هر عملیات یا درخواستی که به یکی از مدل‌های child اشاره دارد، همچنان جدا از مدل parent نوشته می‌شود.

Staticها

Staticها برای تعریف توابعی که در سطح مدل عمل می‌کنند بسیار مفید می‌باشد. آن‌ها به ما اجازه می‌دهند تا توابع با قابلیت استفاده مجدد را برای عملیات مربوط به کل مدل تعریف کنیم. همچنین، به کپسوله کردن منطقی که در مدل به جای داکیومنت‌های individual اعمال می‌شود کمک می‌کنند و کد ما را تمیزتر، سازماندهی شده‌تر و قابل نگه‌داری‌تر می‌کنند.

متدهایی مانند find، findOne، findById و غیره همگی متدهایی هستند که به مدل متصل می‌باشند. با استفاده از ویژگی statics مربوط به schema های Mongoose، می‌توانیم متد مدل خود را بسازیم.

ویژگی Statics بسیار قدرتمند است. با استفاده از آن‌ها، می‌توانیم کوئری‌های پیچیده‌ای را کپسوله نماییم که ممکن است بخواهیم دوباره از آن‌ها استفاده کنیم. علاوه بر این، می‌توانیم برای عملیات‌هایی که داده‌ها را اصلاح یا جمع‌آوری می‌کنند، Statics ایجاد کنیم. مانند شمارش داکیومنت‌ها یا یافتن آن‌ها بر اساس معیارهای خاص.

بررسی موارد استفاده از staticها

Staticها به راحتی ساخته می‌شوند. ما با استفاده از آبجکت statics روی schema خود یک متد static تعریف می‌کنیم.

در فایل user.model.js، این متدهای static countUsers و findByEmail را اضافه می‌نماییم:

// model method
userSchema.statics.countUsers = function () {
    return this.countDocuments({});
};

// model method
userSchema.statics.findByEmail = async function (email) {
  return await this.findOne({ email });
};

داخل هر متد static، this به خود مدل اشاره می‌کند. در این مثال، this در this.findOne({ email }) به مدل User اشاره دارد.

به عنوان مثال:

const user = await User.findByEmail("foo@bar.com");
//or
const client = await Client.findByEmail("foo@bar.com");
//or
const seller = await Seller.findByEmail("foo@bar.com");
//or
const admin = await Admin.findByEmail("foo@bar.com");

وقتی متد static را روی مدل خود فراخوانی می‌کنیم، متد فراخوانی می‌شود و this با مدلی که statics را روی آن فراخوانی کرده‌ایم، جایگزین می‌گردد. این خط یک کوئری را برای یافتن یک داکیومنت واحد در کالکشن MongoDB انجام می‌دهد که در آن فیلد email با آرگومان email ارائه شده مطابقت دارد.

Methodها

Methodها توابعی هستند که می‌توانیم آن‌ها را روی یک schema تعریف کنیم. همینطور می‌توانیم آن‌ها را بر روی نمونه‌هایی از داکیومنت‌های ایجاد شده از این schema فراخوانی نماییم. Methodها به کپسوله کردن منطق در خود داکیومنت کمک کرده و کد ما را تمیزتر و ماژولارتر می‌کنند‌.

با استفاده از متدهای نمونه، می‌توانیم به راحتی با داده‌های مرتبط با داکیومنت‌های خاص تعامل داشته باشیم و آن‌ها را دستکاری کنیم.

بررسی موارد استفاده از Methodها

می‌توانیم با استفاده از آبجکت methods، methodها را روی schema تعریف کنیم.

در فایل user.model.js، یک method داکیومنت اضافه می‌کنیم که از طریق آن می‌توانیم رمز عبور یک کاربر را بررسی نماییم:

// instance or document method
userSchema.methods.getProfile = function () {
    return `${this.name} (${this.email})`;
};

// instance or document method
userSchema.methods.checkPassword = function (password) {
    return password === this.password ? true : false;
};

داخل هر method داکیومنت، this به خود داکیومنت اشاره دارد. در این مثال، this در this.password به داکیومنت user اشاره می‌کند که در آن متد فراخوانی می‌شود. یعنی ما می‌توانیم به تمام فیلدهای این داکیومنت دسترسی داشته باشیم. این موضوع بسیار ارزشمند است. زیرا، می‌توانیم هر چیزی را که مربوط به این داکیومنت است را بازیابی، اصلاح و بررسی نماییم.

به عنوان مثال:

const client = await Client.findById(...)
client.checkPassword("12345")
//or
const seller = await Seller.findById(...)
seller.checkPassword("12345")
//or
const admin = await Admin.findById(...)
admin.checkPassword("12345")

از آنجایی که methodها توابع instance-level هستند، در داکیومنت‌ها فراخوانی می‌شوند. await Client.findById(...) داکیومنتی را return می‌کند که دارای تمام متدهای داخلی و همچنین متدهای از پیش تعریف شده ما، یعنی checkPassword و getProfile می‌باشد.

بنابراین با فراخوانی آن، برای مثال client.checkPassword("12345")، کلمه کلیدی this در تعریف تابع checkPassword با داکیومنت‌ client جایگزین می‌شود. این کار رمز عبور کاربر را با رمز عبور ذخیره شده قبلی در پایگاه داده مقایسه می‌کند.

Query Builder

query builder در Mongoose یک متد سفارشی است که می‌توانیم آن را روی آبجکت query تعریف کنیم تا الگوهای کوئری رایج را ساده و کپسوله کنیم. این query builderها به ما این امکان را می‌دهند تا منطق کوئری با قابلیت استفاده مجدد و readable بسازیم که کار با داده‌ها را آسان‌تر می‌کند.

یکی از با ارزش‌ترین کاربردهای query builderها، chaining است. می‌توانیم آن‌ها را با سایر query builderهایی که ساخته‌ایم یا با متدهای کوئری استاندارد مانند find، sort و غیره chain کنیم.

بررسی موارد استفاده از Query builder

ما query builderها را با افزودن آن‌ها به ویژگی query یک Mongoose schema تعریف می‌کنیم.

در فایل user.model.js، یک متد کوئری کمکی می‌سازیم که به ما کمک می‌کند تا pagination را پیاده‌سازی نماییم.

// query helper
userSchema.query.paginate = function ({ page, limit }) {
    // some code
    const skip = limit * (page - 1);
    return this.skip(skip).limit(limit);
};

برای پیاده‌سازی pagination، به دو متغیر مهم نیاز داریم: متغیر اول شماره صفحه، و متغیر دوم، تعداد مواردی است که در هر صفحه باید بازیابی کنیم.

برای پرس و جو از پایگاه داده برای تعداد مشخصی از داکیومنت‌ها، همیشه از متدهای جستجوی داخلی skip و limit در mongoose استفاده می‌کنیم. متد skip برای تنظیم cursor پس از تعداد معینی از داکیومنت‌ها استفاده می‌شود و پس از آن کوئری اجرا می‌گردد. متد limit نیز برای بازیابی تعداد خاصی از داکیومنت‌ها مورد استفاده قرار می‌گیرد.

داخل هر متد query builder، this به خود کوئری اشاره دارد؛ و از آنجایی که query builderها chainable هستند، می‌توانیم هر یک از آن‌ها را پس از یکدیگر فراخوانی نماییم.

درنهایت، هر متد query builder باید یک mongoose query object را return کند، به همین دلیل است که باید return this.skip(skip).limit(limit) را بنویسیم.

به عنوان مثال:

const results = await Client.find().paginate({ page: 2, limit: 5 });
//or
const results = await Seller.find().paginate({ page: 2, limit: 5 });
//or
const results = await Admin.find().paginate({ page: 2, limit: 5 });

سپس می‌توانیم آن را در هر کوئری فراخوانی کنیم و منتظر بمانیم که await Client.find().paginate({ page: 2, limit: 5 }) تابع paginate را فراخوانی کرده و با استفاده از query builder کلمه کلیدی this را با Client.find() جایگزین کند.

ما می‌توانیم pagination را با شرایط خاصی پیاده‌سازی نماییم، اما همیشه skip و limit را فراخوانی می‌کنیم. با تعریف query builder paginate، منظور خود را تکرار نخواهیم کرد و در نتیجه می‌توانیم منطق مورد نظر را در یک تابع واحد کپسوله کنیم.

Hookها

Hookها، که به عنوان middleware نیز شناخته می‌شوند، توابعی هستند که در نقاط خاصی از lifecycle یک داکیومنت اجرا می‌شوند. آن‌ها به ما اجازه می‌دهند تا رفتار سفارشی را قبل یا بعد از عملیات خاصی مانند ذخیره، به‌روزرسانی یا حذف داکیومنت‌ها اضافه نماییم.

انواع هوک‌ها عبارتند از:

بررسی موارد استفاده از Hookها

در فایل user.model.js، یک middleware ذخیره post اضافه می‌کنیم که از طریق آن می‌توانیم پس از ذخیره داکیومنت user در پایگاه داده، ایمیلی برای فعال‌سازی حساب کاربری ارسال نماییم.

// post hook
userSchema.post("save", async function (doc, next) {
  // send email logic
  // if succeeded
  return next();
  // if failed
  return next(new Error("Failed to send email!"));
});

پس از ایجاد یک کاربر از طریق model.create() یا هر زمانی که متد save() را در داکیومنت user فراخوانی کنیم، تابع callback فراخوانی می‌شود.

در این مثال، اگر لازم باشد که از ارسال ایمیل در save خودداری کنیم، باید یک شرط بنویسیم تا مطمئن شویم که این save فقط مخصوص یک کاربر جدید می‌باشد. برای این کار، می‌توانیم کدی شبیه به if (doc.createdAt.getTime() === doc.updatedAt.getTime()) را بنویسیم.

جمع‌بندی

ما در این مقاله سعی کردیم یک دید کلی از ویژگی‌های schema در Mongoose به دست بیاوریم. همچنین چهار مفهوم کلیدی را بررسی کردیم که عبارتند از: discriminator‌ها، staticها، methodها و hookها.

Discriminatorها به ما اجازه می‌دهند تاچندین مدل ایجاد کنیم که یک schema مشترک را به اشتراک می‌گذارند، که انواع مختلف داکیومنت‌ها را قادر می‌سازد در یک collection واحد ذخیره شوند. این امر مدیریت داده‌ها و کوئری‌ها را تسهیل می‌کند.

Staticها متدهای model-level هستند که قابلیت استفاده مجدد را برای کل مدل ارائه می‌کنند. آن‌ها کوئری‌های پیچیده و منطق دستکاری داده‌ها را کپسوله می‌کنند و باعث می‌شوند تا کد تمیزی در پایگاه کد خود داشته باشیم.

Methodها توابع instance-level هستند که بر روی نمونه‌های داکیومنت جداگانه عمل می‌کنند. آن‌ها رفتارهای سفارشی و دستکاری داده‌ها را برای هر داکیومنت مجاز می‌کنند، بنابراین می‌توانیم داده‌های داکیومنت را به روشی خاص تغییر دهیم، مانند formatting یا محاسبه مقادیر بر اساس فیلدهای آن.

Hookها (یا middleware) به ما این امکان را می‌دهند تا توابع را در نقاط خاصی از lifecycle داکیومنت اجرا کنیم، مانند قبل یا بعد از ذخیره، به‌روزرسانی یا حذف یک داکیومنت. این کار برای پیاده‌سازی اعتبارسنجی، ورود به سیستم یا هر گونه side effect دیگر مربوط به عملیات پایگاه داده مفید می‌باشد.

این ویژگی‌ها باهم تطبیق‌پذیری و سازماندهی مدل‌های Mongoose را افزایش می‌دهند و ساخت برنامه‌های قوی و قابل نگه‌داری با MongoDB را آسان‌تر می‌کنند.