A comprehensive MongoDB Object Document Mapper (ODM) for AdonisJS v6 that provides a familiar Lucid ORM-like interface for working with MongoDB databases. Built with TypeScript for maximum type safety and developer experience.
- 🎯 Familiar API: 100% Lucid ORM-compatible interface for easy adoption
- 🏗️ Decorator-based Models: Use decorators to define your model schema and relationships
- 🔍 Fluent Query Builder: Chainable query methods with MongoDB-specific operations
- 📅 Automatic Timestamps: Auto-managed
createdAt
andupdatedAt
fields - 🔄 Model Lifecycle: Track model state with
$isPersisted
,$dirty
, etc. - 📄 Pagination: Built-in pagination support with metadata
- 🔗 Connection Management: Multiple MongoDB connection support
- 🛡️ Type Safety: Full TypeScript support with IntelliSense and compile-time checking
- 💾 Database Transactions: Full ACID transaction support with managed and manual modes
- 📦 Embedded Documents: Type-safe embedded document support with full CRUD operations
- 🔗 Relationships: Type-safe referenced relationships (@hasOne, @hasMany, @belongsTo)
- 🪝 Lifecycle Hooks: Comprehensive hook system (beforeSave, afterSave, beforeCreate, etc.)
- 🔍 Advanced Querying: Complex filtering, aggregation, and embedded document querying
- 🌱 Database Seeders: Comprehensive seeding system with environment control, execution ordering, and dependency management
- ⚡ Performance: Bulk operations, connection pooling, and optimized queries
- 🛠️ CLI Tools: Ace commands for model generation, seeders, and database operations
- 🧪 Testing Support: Built-in testing utilities and Docker integration
Install the package from the npm registry as follows:
npm i adonis-odm
yarn add adonis-odm
pnpm add adonis-odm
Next, configure the package by running the following ace command:
node ace configure adonis-odm
The configure command will:
- Register the MongoDB provider inside the
adonisrc.ts
file - Create the
config/odm.ts
configuration file - Add environment variables to your
.env
file - Set up validation rules for environment variables
The configuration for the ODM is stored inside the config/odm.ts
file. You can define one or more NoSQL database connections inside this file. Currently supports MongoDB, with DynamoDB support planned.
import env from '#start/env'
import { defineConfig } from 'adonis-odm'
const odmConfig = defineConfig({
connection: 'mongodb',
connections: {
mongodb: {
client: 'mongodb',
connection: {
// Option 1: Use a full URI
url: env.get('MONGO_URI'),
// Option 2: Use individual components (if url is not provided)
host: env.get('MONGO_HOST', 'localhost'),
port: env.get('MONGO_PORT', 27017),
database: env.get('MONGO_DATABASE'),
// MongoDB connection options
options: {
maxPoolSize: env.get('MONGO_MAX_POOL_SIZE', 10),
minPoolSize: env.get('MONGO_MIN_POOL_SIZE', 0),
maxIdleTimeMS: env.get('MONGO_MAX_IDLE_TIME_MS', 30000),
serverSelectionTimeoutMS: env.get('MONGO_SERVER_SELECTION_TIMEOUT_MS', 5000),
socketTimeoutMS: env.get('MONGO_SOCKET_TIMEOUT_MS', 0),
connectTimeoutMS: env.get('MONGO_CONNECT_TIMEOUT_MS', 10000),
},
},
},
},
})
export default odmConfig
The following environment variables are used by the MongoDB configuration:
# Basic Connection Settings
MONGO_HOST=localhost
MONGO_PORT=27017
MONGO_DATABASE=your_database_name
MONGO_URI=mongodb://localhost:27017/your_database_name
# Authentication (optional)
MONGO_USERNAME=your_username
MONGO_PASSWORD=your_password
# Connection Pool Settings (optional)
MONGO_MAX_POOL_SIZE=10
MONGO_MIN_POOL_SIZE=0
MONGO_MAX_IDLE_TIME_MS=30000
MONGO_SERVER_SELECTION_TIMEOUT_MS=5000
MONGO_SOCKET_TIMEOUT_MS=0
MONGO_CONNECT_TIMEOUT_MS=10000
Note: You can use either MONGO_URI
for a complete connection string, or individual components (MONGO_HOST
, MONGO_PORT
, etc.). The URI takes precedence if both are provided.
You can define multiple NoSQL database connections inside the config/odm.ts
file and switch between them as needed:
const odmConfig = defineConfig({
connection: 'primary',
connections: {
primary: {
client: 'mongodb',
connection: {
url: env.get('MONGO_PRIMARY_URI'),
},
},
analytics: {
client: 'mongodb',
connection: {
url: env.get('MONGO_ANALYTICS_URI'),
},
},
},
})
Note: Database transactions require MongoDB 4.0+ and a replica set or sharded cluster configuration. Transactions are not supported on standalone MongoDB instances.
The package provides several ace commands to help you work with MongoDB ODM:
# Configure the package (run this after installation)
node ace configure adonis-odm
# Create a new ODM model
node ace make:odm-model User
# Create a new seeder
node ace make:odm-seeder User
# Create seeder in subdirectory
node ace make:odm-seeder admin/User
# Run all seeders
node ace odm:seed
# Run specific seeder files
node ace odm:seed --files="./database/seeders/user_seeder.ts"
# Run seeders interactively
node ace odm:seed --interactive
# Run seeders for specific connection
node ace odm:seed --connection=analytics
# Test database connection (coming soon)
node ace mongodb:status
# Show database information (coming soon)
node ace mongodb:info
Adonis ODM provides a comprehensive seeding system to populate your MongoDB database with initial or test data. The seeder system follows familiar AdonisJS Lucid patterns while providing MongoDB-specific features and advanced execution control.
Generate a new seeder using the ace command:
# Create a basic seeder
node ace make:odm-seeder User
# Create seeder in subdirectory
node ace make:odm-seeder admin/User
# Use different templates
node ace make:odm-seeder User --stub=simple
node ace make:odm-seeder User --stub=advanced
This creates a seeder file in database/seeders/user_seeder.ts
:
import { BaseSeeder } from 'adonis-odm/seeders'
import User from '#models/user'
export default class UserSeeder extends BaseSeeder {
async run() {
// Insert seed data
await User.createMany([
{
name: 'John Doe',
email: 'john@example.com',
age: 30,
},
{
name: 'Jane Smith',
email: 'jane@example.com',
age: 28,
},
])
}
}
# Run all seeders
node ace odm:seed
# Run specific seeder files
node ace odm:seed --files="./database/seeders/user_seeder.ts"
# Run seeders interactively (choose which ones to run)
node ace odm:seed --interactive
# Run seeders for specific connection
node ace odm:seed --connection=analytics
Control which environments your seeders run in:
import { BaseSeeder } from 'adonis-odm/seeders'
import User from '#models/user'
export default class UserSeeder extends BaseSeeder {
// Only run in development and testing
static environment = ['development', 'testing']
async run() {
await User.createMany([{ name: 'Test User', email: 'test@example.com' }])
}
}
Control the order in which seeders execute using static properties:
import { BaseSeeder } from 'adonis-odm/seeders'
import Role from '#models/role'
export default class RoleSeeder extends BaseSeeder {
// Lower numbers run first
static order = 1
async run() {
await Role.createMany([
{ name: 'admin', permissions: ['*'] },
{ name: 'user', permissions: ['read'] },
])
}
}
Define dependencies between seeders to ensure proper execution order:
import { BaseSeeder } from 'adonis-odm/seeders'
import User from '#models/user'
export default class UserSeeder extends BaseSeeder {
static order = 2
static dependencies = ['RoleSeeder'] // Must run after RoleSeeder
async run() {
const adminRole = await Role.findBy('name', 'admin')
await User.createMany([
{
name: 'Admin User',
email: 'admin@example.com',
roleId: adminRole._id,
},
])
}
}
Create main seeder files (index.ts
or main.ts
) that automatically run first:
// database/seeders/index.ts
import { BaseSeeder } from 'adonis-odm/seeders'
export default class MainSeeder extends BaseSeeder {
// Main seeders automatically get order = 0
async run() {
// Run essential setup logic
console.log('🌱 Starting database seeding...')
}
}
Seed models with embedded documents:
import { BaseSeeder } from 'adonis-odm/seeders'
import User from '#models/user'
export default class UserSeeder extends BaseSeeder {
async run() {
await User.createMany([
{
email: 'john@example.com',
profile: {
firstName: 'John',
lastName: 'Doe',
bio: 'Software Developer',
age: 30,
},
addresses: [
{
type: 'home',
street: '123 Main St',
city: 'New York',
zipCode: '10001',
},
{
type: 'work',
street: '456 Office Blvd',
city: 'New York',
zipCode: '10002',
},
],
},
])
}
}
Seed models with relationships:
import { BaseSeeder } from 'adonis-odm/seeders'
import User from '#models/user'
import Post from '#models/post'
export default class PostSeeder extends BaseSeeder {
static dependencies = ['UserSeeder']
async run() {
const users = await User.all()
for (const user of users) {
await Post.createMany([
{
title: `${user.name}'s First Post`,
content: 'This is my first blog post!',
authorId: user._id,
isPublished: true,
},
{
title: `${user.name}'s Draft`,
content: 'Work in progress...',
authorId: user._id,
isPublished: false,
},
])
}
}
}
Use different database connections for different seeders:
import { BaseSeeder } from 'adonis-odm/seeders'
import AnalyticsEvent from '#models/analytics_event'
export default class AnalyticsSeeder extends BaseSeeder {
// Specify connection in the seeder
connection = 'analytics'
async run() {
await AnalyticsEvent.createMany([
{
event: 'user_signup',
userId: 'user123',
timestamp: new Date(),
metadata: { source: 'web' },
},
])
}
}
Or specify connection when running:
# Run all seeders on analytics connection
node ace odm:seed --connection=analytics
Seeders include comprehensive error handling:
import { BaseSeeder } from 'adonis-odm/seeders'
import User from '#models/user'
export default class UserSeeder extends BaseSeeder {
async run() {
try {
// Check if data already exists
const existingUsers = await User.query().limit(1)
if (existingUsers.length > 0) {
console.log('Users already exist, skipping seeder')
return
}
await User.createMany([{ name: 'John Doe', email: 'john@example.com' }])
console.log('✅ Users seeded successfully')
} catch (error) {
console.error('❌ Error seeding users:', error.message)
throw error // Re-throw to mark seeder as failed
}
}
}
- Use Environment Restrictions: Prevent test data from appearing in production
- Define Clear Dependencies: Use
static dependencies
for complex seeding scenarios - Check for Existing Data: Avoid duplicate data by checking before inserting
- Use Transactions: Wrap complex seeding logic in database transactions
- Provide Feedback: Use console.log to show seeding progress
- Handle Errors Gracefully: Implement proper error handling and cleanup
For more detailed examples and advanced usage patterns, see the seeder documentation and examples.
Import the database service to perform transactions and direct database operations:
import db from 'adonis-odm/services/db'
// Managed transaction (recommended)
const result = await db.transaction(async (trx) => {
// Your operations here
return { success: true }
})
// Manual transaction
const trx = await db.transaction()
try {
// Your operations here
await trx.commit()
} catch (error) {
await trx.rollback()
}
// Direct database access
const mongoClient = db.connection()
const database = db.db()
const collection = db.collection('users')
Create a model by extending BaseModel
and using decorators:
import { BaseModel, column } from 'adonis-odm'
import { DateTime } from 'luxon'
export default class User extends BaseModel {
@column({ isPrimary: true })
declare _id: string
@column()
declare name: string
@column()
declare email: string
@column()
declare age?: number
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}
The ODM provides full support for embedded documents with type safety and CRUD operations.
import { BaseModel, column } from 'adonis-odm'
import { DateTime } from 'luxon'
// Embedded document model
export default class Profile extends BaseModel {
@column()
declare firstName: string
@column()
declare lastName: string
@column()
declare bio?: string
@column()
declare age: number
@column()
declare phoneNumber?: string
// Computed property
get fullName(): string {
return `${this.firstName} ${this.lastName}`
}
}
// Import embedded types
import { EmbeddedSingle, EmbeddedMany } from 'adonis-odm'
// Main model with embedded documents
export default class User extends BaseModel {
@column({ isPrimary: true })
declare _id: string
@column()
declare email: string
@column()
declare age: number
// Single embedded document
@column.embedded(() => Profile, 'single')
declare profile?: EmbeddedSingle<typeof Profile>
// Array of embedded documents
@column.embedded(() => Profile, 'many')
declare profiles?: EmbeddedMany<typeof Profile>
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
// Computed properties (using @computed decorator for serialization)
@computed()
get fullName(): string | null {
return this.profile?.fullName || null
}
@computed()
get allProfileNames(): string[] {
return this.profiles?.map((p) => p.fullName) || []
}
// Helper methods (regular methods, not computed properties)
getYoungProfiles(maxAge: number): InstanceType<typeof Profile>[] {
return this.profiles?.filter((p) => p.age < maxAge) || []
}
getProfilesByBio(bioKeyword: string): InstanceType<typeof Profile>[] {
return this.profiles?.filter((p) => p.bio?.includes(bioKeyword)) || []
}
}
// Create user with embedded profile (single)
const user = await User.create({
email: 'john@example.com',
age: 30,
profile: {
firstName: 'John',
lastName: 'Doe',
bio: 'Software developer',
age: 30,
phoneNumber: '+1234567890',
},
})
// Create user with multiple embedded profiles
const user = await User.create({
email: 'jane@example.com',
age: 28,
profiles: [
{
firstName: 'Jane',
lastName: 'Smith',
bio: 'Technical Lead',
age: 28,
},
{
firstName: 'Jane',
lastName: 'Smith',
bio: 'Architect',
age: 28,
},
],
})
const user = await User.findOrFail('507f1f77bcf86cd799439011')
// ✅ Full IntelliSense support - NO CASTS NEEDED!
if (user.profile) {
const firstName = user.profile.firstName // ✅ Type: string
const lastName = user.profile.lastName // ✅ Type: string
const bio = user.profile.bio // ✅ Type: string | undefined
const age = user.profile.age // ✅ Type: number
const fullName = user.profile.fullName // ✅ Type: string (computed property)
}
// Array operations with full type safety
if (user.profiles) {
// ✅ Standard array methods work with full type safety
const allBios = user.profiles.map((profile) => profile.bio) // ✅ Type: (string | undefined)[]
const leadProfiles = user.profiles.filter(
(profile) => profile.bio?.includes('Lead') // ✅ Type-safe optional chaining
)
// ✅ Type-safe forEach with IntelliSense
user.profiles.forEach((profile, index) => {
// ✅ Full IntelliSense on profile parameter
console.log(`${index + 1}. ${profile.firstName} ${profile.lastName} - ${profile.bio}`)
})
}
const user = await User.findOrFail('507f1f77bcf86cd799439011')
// Single embedded document operations
if (user.profile) {
// Update properties
user.profile.bio = 'Senior Software Engineer'
user.profile.phoneNumber = '+1-555-9999'
// Save the embedded document
await user.profile.save()
}
// Array embedded document operations
if (user.profiles) {
// Update individual items
const firstProfile = user.profiles[0]
firstProfile.bio = 'Senior Technical Lead'
await firstProfile.save()
// Create new embedded document
const newProfile = user.profiles.create({
firstName: 'John',
lastName: 'Doe',
bio: 'Innovation Lead',
age: 32,
})
await newProfile.save()
// Delete embedded document
await firstProfile.delete()
}
The ODM provides a powerful query builder for embedded documents with full type safety:
const user = await User.findOrFail('507f1f77bcf86cd799439011')
if (user.profiles) {
// Type-safe query builder with IntelliSense
const seniorProfiles = user.profiles
.query()
.where('bio', 'like', 'Senior') // ✅ Type-safe field names
.where('age', '>=', 30) // ✅ Type-safe operators
.orderBy('age', 'desc') // ✅ Type-safe sorting
.get()
// Complex filtering
const experiencedDevelopers = user.profiles
.query()
.whereAll([
{ field: 'age', operator: '>=', value: 30 },
{ field: 'bio', operator: 'like', value: 'Developer' },
])
.get()
// Pagination for large datasets
const paginatedResult = user.profiles.query().orderBy('age', 'desc').paginate(1, 5) // page 1, 5 per page
console.log(paginatedResult.data) // Array of profiles
console.log(paginatedResult.pagination) // Pagination metadata
// Search across multiple fields
const searchResults = user.profiles.query().search('Engineer', ['bio', 'firstName']).get()
// Aggregation operations
const ageStats = user.profiles.query().aggregate('age')
console.log(ageStats) // { count, sum, avg, min, max }
// Distinct values
const uniqueAges = user.profiles.query().distinct('age')
// Grouping
const ageGroups = user.profiles.query().groupBy('age')
}
Use the .embed()
method to load embedded documents with type-safe filtering:
// Load all embedded documents
const users = await User.query().embed('profiles').where('email', 'like', '%@company.com').all()
// Load with filtering callback - Full IntelliSense support!
const users = await User.query()
.embed('profiles', (profileQuery) => {
profileQuery
.where('age', '>', 25) // ✅ Type-safe field names
.where('bio', 'like', 'Engineer') // ✅ Type-safe operators
.orderBy('age', 'desc') // ✅ Type-safe sorting
.limit(5) // ✅ Pagination support
})
.where('email', 'like', '%@company.com')
.all()
// Complex embedded filtering
const users = await User.query()
.embed('profiles', (profileQuery) => {
profileQuery
.whereIn('age', [25, 30, 35])
.whereNotNull('bio')
.whereLike('bio', '%Lead%')
.orderBy('firstName', 'asc')
})
.all()
The ODM provides full support for traditional referenced relationships with type-safe decorators and automatic loading.
import { BaseModel, column, hasOne, hasMany, belongsTo } from 'adonis-odm'
import type { HasOne, HasMany, BelongsTo } from 'adonis-odm'
// User model with relationships
export default class User extends BaseModel {
@column({ isPrimary: true })
declare _id: string
@column()
declare name: string
@column()
declare email: string
// One-to-one relationship
@hasOne(() => Profile)
declare profile: HasOne<typeof Profile>
// One-to-many relationship
@hasMany(() => Post)
declare posts: HasMany<typeof Post>
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}
// Profile model with belongs-to relationship
export default class Profile extends BaseModel {
@column({ isPrimary: true })
declare _id: string
@column()
declare firstName: string
@column()
declare lastName: string
@column()
declare userId: string
// Many-to-one relationship
@belongsTo(() => User)
declare user: BelongsTo<typeof User>
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}
// Post model
export default class Post extends BaseModel {
@column({ isPrimary: true })
declare _id: string
@column()
declare title: string
@column()
declare content: string
@column()
declare authorId: string
// Many-to-one relationship
@belongsTo(() => User, { foreignKey: 'authorId' })
declare author: BelongsTo<typeof User>
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}
Use the .load()
method for type-safe relationship loading:
// Load single relationship
const users = await User.query().load('profile').where('isActive', true).all()
// Load multiple relationships
const users = await User.query().load('profile').load('posts').all()
// Load with filtering callback - Full IntelliSense support!
const users = await User.query()
.load('profile', (profileQuery) => {
profileQuery.where('isPublic', true).orderBy('updatedAt', 'desc')
})
.load('posts', (postQuery) => {
postQuery.where('isPublished', true).orderBy('createdAt', 'desc').limit(5)
})
.all()
// Nested relationship loading
const users = await User.query()
.load('posts', (postQuery) => {
postQuery.load('comments').where('isPublished', true)
})
.all()
const user = await User.query().load('profile').load('posts').firstOrFail()
// ✅ Type-safe access with IntelliSense
if (user.profile) {
console.log(user.profile.firstName) // ✅ Type: string
console.log(user.profile.lastName) // ✅ Type: string
}
// ✅ Array relationships with full type safety
if (user.posts) {
user.posts.forEach((post) => {
console.log(post.title) // ✅ Type: string
console.log(post.content) // ✅ Type: string
})
// ✅ Standard array methods work
const publishedPosts = user.posts.filter((post) => post.isPublished)
const postTitles = user.posts.map((post) => post.title)
}
// Create related models
const user = await User.create({ name: 'John', email: 'john@example.com' })
// Create related profile
const profile = await Profile.create({
firstName: 'John',
lastName: 'Doe',
userId: user._id,
})
// Create related posts
const posts = await Post.createMany([
{ title: 'First Post', content: 'Content 1', authorId: user._id },
{ title: 'Second Post', content: 'Content 2', authorId: user._id },
])
// Associate existing models (for belongsTo relationships)
const existingUser = await User.findOrFail('507f1f77bcf86cd799439011')
const newProfile = new Profile()
newProfile.firstName = 'Jane'
newProfile.lastName = 'Smith'
await newProfile.user.associate(existingUser)
AdonisJS Lucid provides two ways to create records:
Method 1: Using .create()
(Recommended)
// Create a single user (no need for 'new')
const user = await User.create({
name: 'John Doe',
email: 'john@example.com',
age: 30,
})
// Create multiple users
const users = await User.createMany([
{ name: 'Jane Smith', email: 'jane@example.com', age: 25 },
{ name: 'Bob Johnson', email: 'bob@example.com', age: 35 },
])
Method 2: Using new
+ .save()
const user = new User()
// Assign properties
user.name = 'John Doe'
user.email = 'john@example.com'
user.age = 30
// Insert to the database
await user.save()
Create or Update
const user = await User.updateOrCreate(
{ email: 'john@example.com' },
{ name: 'John Doe Updated', age: 32 }
)
// Find by ID
const user = await User.find('507f1f77bcf86cd799439011')
const userOrFail = await User.findOrFail('507f1f77bcf86cd799439011')
// Find by field
const user = await User.findBy('email', 'john@example.com')
const userOrFail = await User.findByOrFail('email', 'john@example.com')
// Get first record
const user = await User.first()
const userOrFail = await User.firstOrFail()
// Get all records
const users = await User.all()
AdonisJS Lucid provides three ways to update records:
Method 1: Direct property assignment + save
const user = await User.findOrFail('507f1f77bcf86cd799439011')
user.name = 'Updated Name'
user.age = 31
await user.save()
Method 2: Using .merge()
+ .save()
(Method chaining)
const user = await User.findOrFail('507f1f77bcf86cd799439011')
await user.merge({ name: 'Updated Name', age: 31 }).save()
Method 3: Using query builder .update()
(Bulk update)
// Update multiple records at once
await User.query().where('age', '>=', 18).update({ status: 'adult' })
AdonisJS Lucid provides two ways to delete records:
Method 1: Instance delete
const user = await User.findOrFail('507f1f77bcf86cd799439011')
await user.delete()
Method 2: Query builder bulk delete
// Delete multiple records at once
await User.query().where('isVerified', false).delete()
The query builder provides a fluent interface for building complex queries:
// Simple where clause
const adults = await User.query().where('age', '>=', 18).all()
// Multiple conditions
const users = await User.query().where('age', '>=', 18).where('email', 'like', '%@gmail.com').all()
// OR conditions
const users = await User.query().where('age', '>=', 18).orWhere('email', 'admin@example.com').all()
The ODM supports both MongoDB operators and mathematical symbols:
// Mathematical symbols (more intuitive)
User.query().where('age', '>=', 18)
User.query().where('score', '>', 100)
User.query().where('status', '!=', 'inactive')
// MongoDB operators
User.query().where('age', 'gte', 18)
User.query().where('score', 'gt', 100)
User.query().where('status', 'ne', 'inactive')
Supported operators:
=
,eq
- Equal!=
,ne
- Not equal>
,gt
- Greater than>=
,gte
- Greater than or equal<
,lt
- Less than<=
,lte
- Less than or equalin
- In arraynin
- Not in arrayexists
- Field existsregex
- Regular expressionlike
- Pattern matching with % wildcards
// Null checks
const users = await User.query().whereNull('deletedAt').all()
const users = await User.query().whereNotNull('emailVerifiedAt').all()
// In/Not in arrays
const users = await User.query().whereIn('status', ['active', 'pending']).all()
const users = await User.query().whereNotIn('role', ['admin', 'moderator']).all()
// Between values
const users = await User.query().whereBetween('age', [18, 65]).all()
const users = await User.query().whereNotBetween('age', [13, 17]).all()
// Pattern matching with like
const users = await User.query().where('name', 'like', 'John%').all()
const users = await User.query().whereLike('name', 'John%').all() // Case-sensitive
const users = await User.query().whereILike('name', 'john%').all() // Case-insensitive
// Field existence
const users = await User.query().whereExists('profilePicture').all()
const users = await User.query().whereNotExists('deletedAt').all()
// Negation queries
const users = await User.query().whereNot('status', 'banned').all()
const users = await User.query().whereNot('age', '<', 18).all()
// Complex OR conditions
const users = await User.query()
.where('role', 'admin')
.orWhere('permissions', 'like', '%manage%')
.orWhereIn('department', ['IT', 'Security'])
.orWhereNotNull('specialAccess')
.all()
// Alias methods for clarity
const users = await User.query()
.where('age', '>=', 18)
.andWhere('status', 'active') // Same as .where()
.andWhereNot('role', 'guest') // Same as .whereNot()
.all()
// Sorting
const users = await User.query().orderBy('createdAt', 'desc').orderBy('name', 'asc').all()
// Limiting and pagination
const users = await User.query().limit(10).skip(20).all()
const users = await User.query().offset(20).limit(10).all() // offset is alias for skip
const users = await User.query().forPage(3, 10).all() // page 3, 10 per page
// Field selection
const users = await User.query().select(['name', 'email']).all()
// Distinct values
const uniqueRoles = await User.query().distinct('role').all()
// Grouping and aggregation
const departmentStats = await User.query().groupBy('department').having('count', '>=', 5).all()
// Query cloning
const baseQuery = User.query().where('status', 'active')
const adminQuery = baseQuery.clone().where('role', 'admin')
const userQuery = baseQuery.clone().where('role', 'user')
const paginatedUsers = await User.query().orderBy('createdAt', 'desc').paginate(1, 10) // page 1, 10 per page
console.log(paginatedUsers.data) // Array of users
console.log(paginatedUsers.meta) // Pagination metadata
// Count records
const userCount = await User.query().where('age', '>=', 18).count()
// Get IDs only
const userIds = await User.query().where('status', 'active').ids()
// Delete multiple records
const deletedCount = await User.query().where('status', 'inactive').delete()
// Update multiple records
const updatedCount = await User.query().where('age', '>=', 18).update({ status: 'adult' })
The ODM provides several decorators for defining model properties and their behavior.
@column()
declare name: string
@column({ isPrimary: true })
declare _id: string
// Single embedded document
@column.embedded(() => Profile, 'single')
declare profile?: EmbeddedSingle<typeof Profile>
// Array of embedded documents
@column.embedded(() => Profile, 'many')
declare profiles?: EmbeddedMany<typeof Profile>
// Auto-create timestamp (set only on creation)
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
// Auto-update timestamp (set on creation and updates)
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
// Custom date column
@column.date()
declare birthDate: DateTime
For precise decimal arithmetic and financial data, use the @column.decimal()
decorator to properly handle MongoDB's Decimal128 type:
@column.decimal()
declare price: number
@column.decimal()
declare earnings: number
@column.decimal()
declare taxAmount: number
Why use @column.decimal()
?
Without the decimal decorator, MongoDB decimal values are serialized as objects like { "$numberDecimal": "100.99" }
instead of proper numbers. The decimal decorator:
- Stores values as MongoDB Decimal128 for precision
- Deserializes to JavaScript numbers for calculations
- Serializes to proper numbers in JSON responses
- Handles both
Decimal128
and{ $numberDecimal: "..." }
formats from MongoDB
@column({
serialize: (value) => value.toUpperCase(),
deserialize: (value) => value.toLowerCase(),
})
declare name: string
Computed properties are getter-only properties that are calculated from other model attributes. They are included in JSON serialization but excluded from database operations.
import { BaseModel, column, computed } from 'adonis-odm'
import { DateTime } from 'luxon'
export default class User extends BaseModel {
@column({ isPrimary: true })
declare _id: string
@column()
declare firstName: string
@column()
declare lastName: string
@column()
declare email: string
@column()
declare salary: number
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
// Basic computed property
@computed()
get fullName(): string {
return `${this.firstName} ${this.lastName}`
}
// Computed property with custom serialization name
@computed({ serializeAs: 'display_name' })
get displayName(): string {
return `${this.firstName} ${this.lastName}`.toUpperCase()
}
// Computed property that won't be serialized
@computed({ serializeAs: null })
get internalCalculation(): number {
return this.salary * 0.1 // This won't appear in JSON output
}
// Complex computed property
@computed()
get profileSummary(): string {
const yearsActive = DateTime.now().diff(this.createdAt, 'years').years
return `${this.fullName} (${Math.floor(yearsActive)} years active)`
}
// Computed property based on relationships
@computed()
get hasProfile(): boolean {
return this.profile !== undefined && this.profile !== null
}
}
const user = await User.create({
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
salary: 50000,
})
// Access computed properties directly
console.log(user.fullName) // "John Doe"
console.log(user.displayName) // "JOHN DOE"
console.log(user.profileSummary) // "John Doe (0 years active)"
// Computed properties are included in JSON serialization
const json = user.toJSON()
console.log(json)
// Output:
// {
// _id: "...",
// first_name: "John",
// last_name: "Doe",
// email: "john@example.com",
// salary: 50000,
// created_at: "2024-01-01T00:00:00.000Z",
// updated_at: "2024-01-01T00:00:00.000Z",
// full_name: "John Doe",
// display_name: "JOHN DOE",
// profile_summary: "John Doe (0 years active)",
// has_profile: false
// // Note: internal_calculation is not included (serializeAs: null)
// }
// Computed properties are NOT included in database operations
await user.save() // Only saves actual column data, not computed properties
export default class User extends BaseModel {
@column({ isPrimary: true })
declare _id: string
@column()
declare name: string
@hasOne(() => Profile)
declare profile: HasOne<typeof Profile>
@hasMany(() => Post)
declare posts: HasMany<typeof Post>
// Computed property from loaded relationship
@computed()
get fullName(): string {
return this.profile?.fullName ?? this.name
}
// Computed property with relationship data
@computed()
get postCount(): number {
return this.posts?.length ?? 0
}
// Complex computed property
@computed()
get userStats(): object {
return {
name: this.name,
hasProfile: !!this.profile,
totalPosts: this.postCount,
joinedDate: this.createdAt.toFormat('yyyy-MM-dd'),
}
}
}
// Usage with loaded relationships
const user = await User.query().load('profile').load('posts').firstOrFail()
console.log(user.fullName) // Uses profile data if available
console.log(user.postCount) // Returns actual post count
console.log(user.userStats) // Complex computed object
Use @computed()
decorator when:
- You want the property included in JSON serialization
- You need custom serialization names (
serializeAs
) - You want to exclude from serialization (
serializeAs: null
) - The property represents computed data that should be part of the model's public API
Use regular getters when:
- You want simple helper methods that don't need serialization
- The getter is for internal use only
- You're working with embedded documents where serialization is handled differently
export default class User extends BaseModel {
@column()
declare firstName: string
@column()
declare lastName: string
// ✅ Use @computed() for serialized properties
@computed()
get fullName(): string {
return `${this.firstName} ${this.lastName}`
}
// ✅ Use regular getter for internal helpers
get initials(): string {
return `${this.firstName[0]}${this.lastName[0]}`
}
// ✅ Use @computed() with custom serialization
@computed({ serializeAs: 'display_name' })
get displayName(): string {
return this.fullName.toUpperCase()
}
// ✅ Use @computed() to exclude from serialization
@computed({ serializeAs: null })
get internalId(): string {
return `internal_${this._id}`
}
}
- Keep computations lightweight - Avoid heavy calculations in getters
- Use appropriate return types - TypeScript will infer types automatically
- Handle null/undefined cases - Always check for loaded relationships
- Use meaningful names - Make computed property names descriptive
- Consider serialization - Use
serializeAs
to control JSON output - Avoid side effects - Computed properties should be pure functions
- Choose the right pattern - Use
@computed()
for serialized properties, regular getters for helpers
Models track their state automatically:
const user = new User({ name: 'John' })
console.log(user.$isLocal) // true
console.log(user.$isPersisted) // false
await user.save()
console.log(user.$isLocal) // false
console.log(user.$isPersisted) // true
user.name = 'Jane'
console.log(user.$dirty) // { name: 'Jane' }
The ODM provides a comprehensive hook system that allows you to execute custom logic at various points in the model lifecycle. Hooks are defined using decorators and are executed automatically.
import {
BaseModel,
column,
beforeSave,
afterSave,
beforeCreate,
afterCreate,
beforeUpdate,
afterUpdate,
beforeDelete,
afterDelete,
beforeFind,
afterFind,
beforeFetch,
afterFetch,
} from 'adonis-odm'
export default class User extends BaseModel {
@column({ isPrimary: true })
declare _id: string
@column()
declare name: string
@column()
declare email: string
@column()
declare password: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
// Hooks that run before/after save operations (create and update)
@beforeSave()
static async hashPassword(user: User) {
if (user.$dirty.password) {
user.password = await hash(user.password)
}
}
@afterSave()
static async logSave(user: User) {
console.log(`User ${user.name} was saved`)
}
// Hooks that run before/after create operations
@beforeCreate()
static async validateEmail(user: User) {
const existingUser = await User.findBy('email', user.email)
if (existingUser) {
throw new Error('Email already exists')
}
}
@afterCreate()
static async sendWelcomeEmail(user: User) {
// Send welcome email logic
console.log(`Welcome email sent to ${user.email}`)
}
// Hooks that run before/after update operations
@beforeUpdate()
static async validateUpdate(user: User) {
if (user.$dirty.email) {
// Validate email change
console.log('Email is being changed')
}
}
@afterUpdate()
static async logUpdate(user: User) {
console.log(`User ${user.name} was updated`)
}
// Hooks that run before/after delete operations
@beforeDelete()
static async checkDependencies(user: User) {
const posts = await Post.query().where('authorId', user._id).count()
if (posts > 0) {
throw new Error('Cannot delete user with existing posts')
}
}
@afterDelete()
static async cleanup(user: User) {
// Cleanup related data
console.log(`Cleanup completed for user ${user.name}`)
}
// Hooks that run before/after find operations
@beforeFind()
static async logFind(query: ModelQueryBuilder<any, User>) {
console.log('Finding user...')
}
@afterFind()
static async logFoundUser(user: User | null) {
if (user) {
console.log(`Found user: ${user.name}`)
}
}
// Hooks that run before/after fetch operations (multiple records)
@beforeFetch()
static async logFetch(query: ModelQueryBuilder<any, User>) {
console.log('Fetching users...')
}
@afterFetch()
static async logFetchedUsers(users: User[]) {
console.log(`Fetched ${users.length} users`)
}
}
Hooks are executed in the following order:
For Create Operations:
beforeSave
beforeCreate
- Database operation
afterCreate
afterSave
For Update Operations:
beforeSave
beforeUpdate
- Database operation
afterUpdate
afterSave
For Delete Operations:
beforeDelete
- Database operation
afterDelete
For Find Operations:
beforeFind
- Database operation
afterFind
For Fetch Operations:
beforeFetch
- Database operation
afterFetch
Before hooks can abort operations by returning false
:
export default class User extends BaseModel {
@beforeSave()
static async validateUser(user: User) {
if (!user.email.includes('@')) {
console.log('Invalid email format')
return false // Aborts the save operation
}
}
@beforeDelete()
static async preventAdminDeletion(user: User) {
if (user.role === 'admin') {
console.log('Cannot delete admin user')
return false // Aborts the delete operation
}
}
}
- Keep hooks lightweight - Avoid heavy computations in hooks
- Use async/await - Hooks support asynchronous operations
- Handle errors gracefully - Use try/catch blocks for error handling
- Return false to abort - Use return false in before hooks to prevent operations
- Use appropriate hook types - Choose the right hook for your use case
The MongoDB ODM provides full ACID transaction support, similar to AdonisJS Lucid ORM. Transactions ensure that multiple database operations are executed atomically - either all operations succeed, or all are rolled back.
Managed transactions automatically handle commit and rollback operations:
import db from 'adonis-odm/services/db'
// Managed transaction with automatic commit/rollback
const newUser = await db.transaction(async (trx) => {
// Create user within transaction
const user = await User.create(
{
name: 'John Doe',
email: 'john@example.com',
},
{ client: trx }
)
// Create related profile within same transaction
const profile = await Profile.create(
{
userId: user._id,
firstName: 'John',
lastName: 'Doe',
},
{ client: trx }
)
// If any operation fails, entire transaction is rolled back
// If all operations succeed, transaction is automatically committed
return user
})
console.log('Transaction completed successfully:', newUser.toJSON())
For more control, you can manually manage transaction lifecycle:
// Manual transaction with explicit commit/rollback
const trx = await db.transaction()
try {
// Create user within transaction
const user = await User.create(
{
name: 'Jane Smith',
email: 'jane@example.com',
},
{ client: trx }
)
// Update user within transaction
await User.query({ client: trx }).where('_id', user._id).update({ age: 30 })
// Manually commit the transaction
await trx.commit()
console.log('Transaction committed successfully')
} catch (error) {
// Manually rollback on error
await trx.rollback()
console.error('Transaction rolled back:', error)
}
You can associate model instances with transactions:
await db.transaction(async (trx) => {
const user = new User()
user.name = 'Bob Johnson'
user.email = 'bob@example.com'
// Associate model with transaction
user.useTransaction(trx)
await user.save()
// Update the same instance
user.age = 35
await user.save() // Uses the same transaction
})
All query builder operations support transactions:
const trx = await db.transaction()
try {
// Query with transaction
const users = await User.query({ client: trx }).where('isActive', true).all()
// Update multiple records
const updateCount = await User.query({ client: trx })
.where('age', '>=', 18)
.update({ status: 'adult' })
// Delete records
const deleteCount = await User.query({ client: trx }).where('isVerified', false).delete()
await trx.commit()
} catch (error) {
await trx.rollback()
throw error
}
You can pass MongoDB-specific transaction options:
// With transaction options
const result = await db.transaction(
async (trx) => {
// Your operations here
return await User.create({ name: 'Test' }, { client: trx })
},
{
readConcern: { level: 'majority' },
writeConcern: { w: 'majority' },
readPreference: 'primary',
}
)
// Manual transaction with options
const trx = await db.transaction({
readConcern: { level: 'majority' },
writeConcern: { w: 'majority' },
})
Transactions automatically rollback on errors:
try {
await db.transaction(async (trx) => {
await User.create({ name: 'Test User' }, { client: trx })
// This will cause the entire transaction to rollback
throw new Error('Something went wrong')
})
} catch (error) {
console.log('Transaction was automatically rolled back')
// The user creation above was not persisted
}
- Use managed transactions when possible for automatic error handling
- Keep transactions short to minimize lock time
- Handle errors appropriately and always rollback on failure
- Use transactions for related operations that must succeed or fail together
- Pass transaction client to all operations that should be part of the transaction
You can work with multiple MongoDB connections:
// In your model
export default class User extends BaseModel {
static getConnection(): string {
return 'secondary' // Use a different connection
}
}
// Using different connections in queries
const primaryUsers = await User.query().all() // Uses default connection
const analyticsUsers = await User.query({ connection: 'analytics' }).all() // Uses analytics connection
// Direct database access with specific connections
const primaryDb = db.connection('primary')
const analyticsDb = db.connection('analytics')
The ODM provides comprehensive error handling with custom exception types for different scenarios.
import {
MongoOdmException,
ModelNotFoundException,
ConnectionException,
DatabaseOperationException,
ValidationException,
TransactionException,
} from 'adonis-odm'
// Base exception for all ODM errors
try {
// ODM operations
} catch (error) {
if (error instanceof MongoOdmException) {
console.log('ODM Error:', error.message)
}
}
// Model not found exception
try {
const user = await User.findOrFail('invalid-id')
} catch (error) {
if (error instanceof ModelNotFoundException) {
console.log('User not found:', error.message)
// Error message: "User with identifier "invalid-id" not found"
}
}
// Connection exception
try {
await db.connect()
} catch (error) {
if (error instanceof ConnectionException) {
console.log('Connection failed:', error.message)
// Error message: "Failed to connect to MongoDB connection "primary": ..."
}
}
// Database operation exception
try {
await User.query().where('invalid.field', 'value').all()
} catch (error) {
if (error instanceof DatabaseOperationException) {
console.log('Database operation failed:', error.message)
// Error message: "Database operation "find" failed: ..."
}
}
// Validation exception
try {
const user = new User()
user.email = 'invalid-email'
await user.save()
} catch (error) {
if (error instanceof ValidationException) {
console.log('Validation failed:', error.message)
// Error message: "Validation failed for field "email" with value "invalid-email": must be a valid email"
}
}
// Transaction exception
try {
await db.transaction(async (trx) => {
// Transaction operations that fail
throw new Error('Something went wrong')
})
} catch (error) {
if (error instanceof TransactionException) {
console.log('Transaction failed:', error.message)
// Error message: "Transaction operation "commit" failed: ..."
}
}
// 1. Use specific exception types for targeted error handling
export default class UserController {
async show({ params, response }: HttpContext) {
try {
const user = await User.findOrFail(params.id)
return user
} catch (error) {
if (error instanceof ModelNotFoundException) {
return response.status(404).json({ error: 'User not found' })
}
throw error // Re-throw other errors
}
}
async store({ request, response }: HttpContext) {
try {
const userData = request.only(['name', 'email'])
const user = await User.create(userData)
return response.status(201).json(user)
} catch (error) {
if (error instanceof ValidationException) {
return response.status(422).json({ error: error.message })
}
if (error instanceof DatabaseOperationException) {
return response.status(500).json({ error: 'Database error occurred' })
}
throw error
}
}
}
// 2. Use global exception handler for consistent error responses
export default class HttpExceptionHandler extends ExceptionHandler {
async handle(error: unknown, ctx: HttpContext) {
if (error instanceof ModelNotFoundException) {
return ctx.response.status(404).json({
error: 'Resource not found',
message: error.message,
})
}
if (error instanceof ValidationException) {
return ctx.response.status(422).json({
error: 'Validation failed',
message: error.message,
})
}
if (error instanceof ConnectionException) {
return ctx.response.status(503).json({
error: 'Service unavailable',
message: 'Database connection failed',
})
}
return super.handle(error, ctx)
}
}
// 3. Graceful error handling in transactions
async function transferData() {
try {
await db.transaction(async (trx) => {
const user = await User.create({ name: 'John' }, { client: trx })
const profile = await Profile.create({ userId: user._id }, { client: trx })
// If any operation fails, transaction is automatically rolled back
return { user, profile }
})
} catch (error) {
if (error instanceof TransactionException) {
console.log('Transaction failed and was rolled back')
}
// Handle other errors
}
}
The ODM supports efficient bulk operations for better performance:
// Bulk create
const users = await User.createMany([
{ name: 'User 1', email: 'user1@example.com' },
{ name: 'User 2', email: 'user2@example.com' },
{ name: 'User 3', email: 'user3@example.com' },
])
// Bulk update
const updateCount = await User.query().where('isActive', false).update({ status: 'inactive' })
// Bulk delete
const deleteCount = await User.query()
.where('lastLoginAt', '<', DateTime.now().minus({ months: 6 }))
.delete()
// Bulk upsert (update or create)
const results = await User.updateOrCreateMany(
'email', // Key field
[
{ email: 'user1@example.com', name: 'Updated User 1' },
{ email: 'user4@example.com', name: 'New User 4' },
]
)
MongoDB connection pooling is automatically configured for optimal performance:
// Configure connection pool in config/odm.ts
const odmConfig = defineConfig({
connections: {
mongodb: {
client: 'mongodb',
connection: {
url: env.get('MONGO_URI'),
options: {
maxPoolSize: 20, // Maximum connections in pool
minPoolSize: 5, // Minimum connections in pool
maxIdleTimeMS: 30000, // Close connections after 30s idle
serverSelectionTimeoutMS: 5000, // Timeout for server selection
socketTimeoutMS: 0, // No socket timeout
connectTimeoutMS: 10000, // 10s connection timeout
},
},
},
},
})
// Use indexes for better query performance
const users = await User.query()
.where('email', 'john@example.com') // Ensure email field is indexed
.where('isActive', true) // Compound index on email + isActive
.first()
// Limit fields to reduce data transfer
const users = await User.query()
.select(['name', 'email']) // Only fetch required fields
.where('isActive', true)
.all()
// Use pagination for large datasets
const paginatedUsers = await User.query()
.where('isActive', true)
.orderBy('createdAt', 'desc')
.paginate(1, 50) // Page 1, 50 records per page
// Efficient counting
const activeUserCount = await User.query().where('isActive', true).count() // More efficient than fetching all records
// Eager load relationships to prevent N+1 queries
const users = await User.query()
.load('profile')
.load('posts', (postQuery) => {
postQuery.limit(5).orderBy('createdAt', 'desc')
})
.where('isActive', true)
.all()
// Bulk load relationships for multiple models
const userIds = ['id1', 'id2', 'id3']
const users = await User.query().whereIn('_id', userIds).load('profile').all()
// Efficient embedded document queries
const users = await User.query()
.embed('profiles', (profileQuery) => {
profileQuery.where('age', '>', 25).orderBy('age', 'desc').limit(3) // Limit embedded results
})
.where('isActive', true)
.all()
// Aggregation on embedded documents
const userStats = await User.query()
.where('profiles.age', '>', 18)
.aggregate([
{ $unwind: '$profiles' },
{ $group: { _id: null, avgAge: { $avg: '$profiles.age' } } },
])
// Model-level caching (implement in your application)
class CachedUser extends User {
static async findCached(id: string): Promise<User | null> {
const cacheKey = `user:${id}`
let user = await cache.get(cacheKey)
if (!user) {
user = await this.find(id)
if (user) {
await cache.set(cacheKey, user, { ttl: 300 }) // 5 minutes
}
}
return user
}
}
// Query result caching
const cacheKey = 'active-users'
let activeUsers = await cache.get(cacheKey)
if (!activeUsers) {
activeUsers = await User.query().where('isActive', true).all()
await cache.set(cacheKey, activeUsers, { ttl: 60 }) // 1 minute
}
// Complex aggregation pipelines
const userStats = await User.aggregate([
{ $match: { isActive: true } },
{
$group: {
_id: '$department',
count: { $sum: 1 },
avgAge: { $avg: '$age' },
maxSalary: { $max: '$salary' },
},
},
{ $sort: { count: -1 } },
])
// Geospatial queries (if using location data)
const nearbyUsers = await User.query()
.where('location', 'near', {
geometry: { type: 'Point', coordinates: [longitude, latitude] },
maxDistance: 1000, // meters
})
.all()
// Text search
const searchResults = await User.query()
.where('$text', { $search: 'john developer' })
.orderBy({ score: { $meta: 'textScore' } })
.all()
// Complex filtering with $expr
const users = await User.query()
.where('$expr', {
$gt: [{ $size: '$posts' }, 10], // Users with more than 10 posts
})
.all()
// Use streams for large datasets
const userStream = User.query().where('isActive', true).stream()
userStream.on('data', (user) => {
// Process each user individually
processUser(user)
})
userStream.on('end', () => {
console.log('Finished processing all users')
})
// Cursor-based pagination for large datasets
let cursor = null
const batchSize = 1000
do {
const query = User.query().limit(batchSize)
if (cursor) {
query.where('_id', '>', cursor)
}
const users = await query.orderBy('_id').all()
if (users.length > 0) {
cursor = users[users.length - 1]._id
await processBatch(users)
}
if (users.length < batchSize) {
break // No more data
}
} while (true)
query(options?)
- Create a new query builderfind(id, options?)
- Find by IDfindOrFail(id, options?)
- Find by ID or throwfindBy(field, value)
- Find by fieldfindByOrFail(field, value)
- Find by field or throwfirst()
- Get first recordfirstOrFail()
- Get first record or throwall()
- Get all recordscreate(attributes, options?)
- Create new recordcreateMany(attributesArray)
- Create multiple recordsupdateOrCreate(search, update)
- Update existing or create new
save()
- Save the modeldelete()
- Delete the modelfill(attributes)
- Fill with attributesmerge(attributes)
- Merge attributestoDocument()
- Convert to plain objectuseTransaction(trx)
- Associate model with transaction
$isPersisted
- Whether the model exists in database$isLocal
- Whether the model is local only$dirty
- Object containing modified attributes$original
- Original values before modifications$trx
- Associated transaction client (if any)
Define a regular database column.
Options:
isPrimary?: boolean
- Mark as primary keyserialize?: (value: any) => any
- Custom serialization functiondeserialize?: (value: any) => any
- Custom deserialization functionserializeAs?: string | null
- Custom JSON key name
Define a DateTime column with automatic timestamp handling.
Options:
autoCreate?: boolean
- Set timestamp on creationautoUpdate?: boolean
- Update timestamp on saveserialize?: (value: DateTime) => any
- Custom serializationdeserialize?: (value: any) => DateTime
- Custom deserialization
Define a Date column.
Define an embedded document column.
Parameters:
model: () => BaseModel
- Model class for the embedded documenttype: 'single' | 'many'
- Single document or array of documentsoptions?: ColumnOptions
- Additional column options
Define a computed property (getter-only).
Options:
serializeAs?: string | null
- Custom JSON key name (null to exclude from serialization)
Define a one-to-one relationship.
Parameters:
model: () => BaseModel
- Related model classoptions?: { localKey?: string, foreignKey?: string }
- Key configuration
Define a one-to-many relationship.
Parameters:
model: () => BaseModel
- Related model classoptions?: { localKey?: string, foreignKey?: string }
- Key configuration
Define a many-to-one relationship.
Parameters:
model: () => BaseModel
- Related model classoptions?: { localKey?: string, foreignKey?: string }
- Key configuration
@beforeSave()
- Before create or update@afterSave()
- After create or update@beforeCreate()
- Before create only@afterCreate()
- After create only@beforeUpdate()
- Before update only@afterUpdate()
- After update only@beforeDelete()
- Before delete@afterDelete()
- After delete@beforeFind()
- Before find operations@afterFind()
- After find operations@beforeFetch()
- Before fetch operations@afterFetch()
- After fetch operations
where(field, value)
- Add where conditionwhere(field, operator, value)
- Add where condition with operatorandWhere(field, value)
- Alias for where methodwhereNot(field, value)
- Add where not conditionwhereNot(field, operator, value)
- Add where not condition with operatorandWhereNot(field, value)
- Alias for whereNot method
orWhere(field, value)
- Add OR where conditionorWhere(field, operator, value)
- Add OR where condition with operatororWhereNot(field, value)
- Add OR where not conditionorWhereNot(field, operator, value)
- Add OR where not condition with operator
whereLike(field, pattern)
- Case-sensitive pattern matchingwhereILike(field, pattern)
- Case-insensitive pattern matching
whereNull(field)
- Where field is nullwhereNotNull(field)
- Where field is not nullorWhereNull(field)
- OR where field is nullorWhereNotNull(field)
- OR where field is not null
whereExists(field)
- Where field existswhereNotExists(field)
- Where field does not existorWhereExists(field)
- OR where field existsorWhereNotExists(field)
- OR where field does not exist
whereIn(field, values)
- Where field is in arraywhereNotIn(field, values)
- Where field is not in arrayorWhereIn(field, values)
- OR where field is in arrayorWhereNotIn(field, values)
- OR where field is not in array
whereBetween(field, [min, max])
- Where field is between valueswhereNotBetween(field, [min, max])
- Where field is not between valuesorWhereBetween(field, [min, max])
- OR where field is between valuesorWhereNotBetween(field, [min, max])
- OR where field is not between values
distinct(field)
- Get distinct values for fieldgroupBy(...fields)
- Group results by fieldshaving(field, value)
- Add having condition for grouped resultshaving(field, operator, value)
- Add having condition with operator
orderBy(field, direction)
- Add sortinglimit(count)
- Limit resultsskip(count)
- Skip resultsoffset(count)
- Alias for skip methodforPage(page, perPage)
- Set pagination using page and perPageselect(fields)
- Select specific fields
load(relationName, callback?)
- Load referenced relationships with optional filteringembed(relationName, callback?)
- Load embedded documents with optional filtering
useTransaction(trx)
- Associate query builder with transaction
clone()
- Clone the query builder instance
first()
- Get first resultfirstOrFail()
- Get first result or throwall()
- Get all resultsfetch()
- Alias for all()paginate(page, perPage)
- Get paginated resultscount()
- Count matching documentsids()
- Get array of IDsupdate(data)
- Update matching documentsdelete()
- Delete matching documents
The EmbeddedQueryBuilder
provides comprehensive querying capabilities for embedded documents with full type safety:
where(field, value)
- Add where conditionwhere(field, operator, value)
- Add where condition with operatorandWhere(field, value)
- Alias for where methodwhereNot(field, value)
- Add where not conditionorWhere(field, value)
- Add OR where conditionorWhereNot(field, value)
- Add OR where not condition
whereLike(field, pattern)
- Case-sensitive pattern matchingwhereILike(field, pattern)
- Case-insensitive pattern matching
whereIn(field, values)
- Where field is in arraywhereNotIn(field, values)
- Where field is not in arrayorWhereIn(field, values)
- OR where field is in arrayorWhereNotIn(field, values)
- OR where field is not in array
whereBetween(field, [min, max])
- Where field is between valueswhereNotBetween(field, [min, max])
- Where field is not between valuesorWhereBetween(field, [min, max])
- OR where field is between valuesorWhereNotBetween(field, [min, max])
- OR where field is not between values
whereNull(field)
- Where field is nullwhereNotNull(field)
- Where field is not nullwhereExists(field)
- Where field existswhereNotExists(field)
- Where field does not exist
whereAll(conditions)
- Add multiple AND conditionswhereAny(conditions)
- Add multiple OR conditionswhereDateBetween(field, startDate, endDate)
- Filter by date rangewhereArrayContains(field, value)
- Filter by array contains valuewhereRegex(field, pattern, flags?)
- Filter by regex pattern
orderBy(field, direction)
- Add sortinglimit(count)
- Limit resultsskip(count)
- Skip resultsoffset(count)
- Alias for skip methodforPage(page, perPage)
- Set pagination using page and perPage
search(term, fields?)
- Search across multiple fieldsselect(...fields)
- Select specific fields
get()
- Get all filtered resultsfirst()
- Get first resultcount()
- Count matching documentsexists()
- Check if any results existpaginate(page, perPage)
- Get paginated results with metadata
distinct(field)
- Get distinct values for fieldgroupBy(field)
- Group results by field valueaggregate(field)
- Get aggregated statistics (sum, avg, min, max, count)
tap(callback)
- Execute callback on resultsclone()
- Clone the query builder instance
transaction(callback, options?)
- Execute managed transactiontransaction(options?)
- Create manual transaction
connection(name?)
- Get MongoDB client connectiondb(name?)
- Get database instancecollection(name, connectionName?)
- Get collection instanceconnect()
- Connect to all configured MongoDB instancesclose()
- Close all connections
commit()
- Commit the transactionrollback()
- Rollback/abort the transaction
collection(name)
- Get collection instance within transactionquery(modelConstructor)
- Create query builder within transactiongetSession()
- Get underlying MongoDB ClientSession
The MongoDB ODM provides Ace commands for generating models and managing your database.
Generate a new ODM model:
node ace make:odm-model User
This creates a new model file in app/models/
with the basic structure:
import { BaseModel, column } from 'adonis-odm'
import { DateTime } from 'luxon'
export default class User extends BaseModel {
@column({ isPrimary: true })
declare _id: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
static getCollectionName(): string {
return 'users'
}
}
Available Commands:
make:odm-model <ModelName>
- Generate a new ODM model class
The MongoDB ODM provides comprehensive testing support with both unit tests and integration tests.
# Run all tests
npm test
# Run integration tests with real MongoDB
npm run test:integration
# Run all tests (unit + integration)
npm run test:all
# Run tests in watch mode
npm run test:watch
# Run tests with coverage
npm run test:coverage
For integration tests, the package includes Docker support:
# Start MongoDB with Docker and run tests
npm run test:docker
# Keep containers running after tests (useful for debugging)
npm run test:docker:keep
import { test } from 'node:test'
import assert from 'node:assert'
import { User, Profile } from '#models'
import db from 'adonis-odm/services/db'
test.describe('User Model', () => {
test.beforeEach(async () => {
// Clean up database before each test
await User.query().delete()
await Profile.query().delete()
})
test('should create a user with embedded profile', async () => {
const user = await User.create({
name: 'John Doe',
email: 'john@example.com',
profile: {
firstName: 'John',
lastName: 'Doe',
age: 30,
},
})
assert.strictEqual(user.name, 'John Doe')
assert.strictEqual(user.profile?.firstName, 'John')
assert.ok(user.$isPersisted)
})
test('should load relationships', async () => {
const user = await User.create({
name: 'Jane Smith',
email: 'jane@example.com',
})
const profile = await Profile.create({
firstName: 'Jane',
lastName: 'Smith',
userId: user._id,
})
const userWithProfile = await User.query().load('profile').findOrFail(user._id)
assert.ok(userWithProfile.profile)
assert.strictEqual(userWithProfile.profile.firstName, 'Jane')
})
test('should handle transactions', async () => {
const result = await db.transaction(async (trx) => {
const user = await User.create(
{
name: 'Transaction User',
email: 'transaction@example.com',
},
{ client: trx }
)
const profile = await Profile.create(
{
firstName: 'Transaction',
lastName: 'User',
userId: user._id,
},
{ client: trx }
)
return { user, profile }
})
assert.strictEqual(result.user.name, 'Transaction User')
assert.strictEqual(result.profile.firstName, 'Transaction')
})
test('should execute hooks', async () => {
let hookExecuted = false
// Temporarily add a hook for testing
const originalHooks = User.getMetadata().hooks
User.getMetadata().hooks = new Map([['afterCreate', ['testHook']]])
// Add the hook method
;(User as any).testHook = () => {
hookExecuted = true
}
await User.create({
name: 'Hook Test',
email: 'hook@example.com',
})
assert.ok(hookExecuted)
// Restore original hooks
User.getMetadata().hooks = originalHooks
})
test('should handle embedded document queries', async () => {
const user = await User.create({
name: 'Embedded Test',
email: 'embedded@example.com',
profiles: [
{ firstName: 'John', lastName: 'Doe', age: 30 },
{ firstName: 'Jane', lastName: 'Smith', age: 25 },
],
})
const userWithFilteredProfiles = await User.query()
.embed('profiles', (profileQuery) => {
profileQuery.where('age', '>', 28)
})
.findOrFail(user._id)
assert.strictEqual(userWithFilteredProfiles.profiles?.length, 1)
assert.strictEqual(userWithFilteredProfiles.profiles?.[0].firstName, 'John')
})
})
For testing, you can use the standard MongoDB ODM features:
import db from 'adonis-odm/services/db'
import { User, Profile } from '#models'
// Clean all collections manually
await User.query().delete()
await Profile.query().delete()
// Or clean specific collections
await db.collection('users').deleteMany({})
await db.collection('profiles').deleteMany({})
// Create test data
const testUsers = await User.createMany([
{ name: 'Test User 1', email: 'test1@example.com' },
{ name: 'Test User 2', email: 'test2@example.com' },
])
// Create test transaction
const result = await db.transaction(async (trx) => {
// Your test operations within transaction
const user = await User.create({ name: 'Test' }, { client: trx })
return user
})
- Clean database between tests - Ensure test isolation
- Use transactions for test data - Easy cleanup and rollback
- Test both success and error cases - Include exception handling
- Mock external dependencies - Focus on ODM functionality
- Use descriptive test names - Make tests self-documenting
The MongoDB ODM provides comprehensive functionality as demonstrated in the examples directory:
examples/app/controllers/cruds_controller.ts
- Complete CRUD operations showcaseexamples/app/models/
- Various model examples with different relationship typesexamples/simple_db_usage.ts
- Basic usage patternsexamples/config/odm.ts
- Configuration examples
- Type-safe embedded document operations
- Advanced query building with the
.embed()
method - CRUD operations on both single and array embedded documents
- Transaction support with error handling
- Relationship loading and filtering with type safety
- Lifecycle hooks implementation
- Error handling patterns
We welcome contributions to the MongoDB ODM! Here's how you can help:
-
Fork and Clone
git clone https://github.com/your-username/adonis-odm.git cd adonis-odm
-
Install Dependencies
npm install
-
Set Up Development Environment
# Copy environment variables cp .env.example .env # Start MongoDB with Docker (for testing) docker-compose up -d mongodb
-
Run Tests
# Run all tests npm test # Run tests with coverage npm run test:coverage # Run integration tests npm run test:integration
-
Create a Feature Branch
git checkout -b feature/your-feature-name
-
Make Your Changes
- Follow the existing code style
- Add tests for new functionality
- Update documentation as needed
-
Run Quality Checks
# Lint code npm run lint # Format code npm run format # Type check npm run typecheck # Run all tests npm run test:all
-
Build the Package
npm run compile
-
Submit a Pull Request
- Provide a clear description of your changes
- Include tests for new features
- Ensure all CI checks pass
- TypeScript: Use strict TypeScript with proper type annotations
- ESLint: Follow the configured ESLint rules
- Prettier: Use Prettier for code formatting
- Naming: Use descriptive names for variables, functions, and classes
- Comments: Add JSDoc comments for public APIs
- Unit Tests: Test individual components in isolation
- Integration Tests: Test real database operations
- Coverage: Maintain high test coverage (>90%)
- Test Structure: Use descriptive test names and organize tests logically
- README: Keep the main README comprehensive and up-to-date
- JSDoc: Document all public APIs with JSDoc comments
- Examples: Provide practical examples for new features
- Changelog: Update the changelog for all changes
When reporting issues, please include:
-
Environment Information
- Node.js version
- MongoDB version
- AdonisJS version
- Package version
-
Reproduction Steps
- Minimal code example
- Expected behavior
- Actual behavior
-
Error Messages
- Full error stack traces
- Relevant log output
For feature requests, please:
- Check Existing Issues - Avoid duplicates
- Provide Use Cases - Explain why the feature is needed
- Consider Implementation - Suggest how it might work
- Discuss First - Open an issue before starting work
-
Version Bumping
# Patch release (bug fixes) npm run release:patch # Minor release (new features) npm run release:minor # Major release (breaking changes) npm run release:major
-
Publishing
# Build and publish npm run compile npm publish
adonis-odm/
├── src/ # Source code
│ ├── base_model/ # BaseModel implementation
│ ├── decorators/ # Column and hook decorators
│ ├── query_builder/ # Query builder implementation
│ ├── relationships/ # Relationship handling
│ ├── embedded/ # Embedded document support
│ ├── exceptions/ # Custom exceptions
│ └── types/ # TypeScript type definitions
├── providers/ # AdonisJS service providers
├── commands/ # Ace commands
├── services/ # Database services
├── stubs/ # Code generation templates
├── examples/ # Usage examples
├── tests/ # Test files
├── docs/ # Documentation
└── scripts/ # Build and utility scripts
- GitHub Issues: For bugs and feature requests
- Discussions: For questions and community support
- Documentation: Check the comprehensive docs in
/docs
- Examples: See practical examples in
/examples
This project is licensed under the MIT License. See the LICENSE.md file for details.
- AdonisJS Team - For the excellent framework and Lucid ORM inspiration
- MongoDB Team - For the robust MongoDB Node.js driver
- Contributors - Thank you to all contributors who help improve this project
See CHANGELOG.md for a detailed history of changes.
If you find this project helpful, please consider:
- ⭐ Starring the repository
- 🐛 Reporting bugs and issues
- 💡 Suggesting new features
- 🤝 Contributing code or documentation
- 📢 Sharing with the community