adonis-odm
Version:
A comprehensive MongoDB ODM for AdonisJS with Lucid-style API, type-safe relationships, embedded documents, and transaction support
1,943 lines (1,489 loc) โข 81.6 kB
Markdown
# MongoDB ODM for AdonisJS v6
[](https://github.com/DreamsHive/adonis-odm/actions/workflows/ci.yml)
[](https://github.com/DreamsHive/adonis-odm/actions/workflows/security.yml)
[](https://github.com/DreamsHive/adonis-odm/actions/workflows/release.yml)
[](https://github.com/DreamsHive/adonis-odm/actions/workflows/docs.yml)
[](https://www.npmjs.com/package/adonis-odm)
[](https://opensource.org/licenses/MIT)
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.
## โจ Features
### Core Features
- ๐ฏ **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` and `updatedAt` 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
### Advanced Features
- ๐พ **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
## Installation
Install the package from the npm registry as follows:
```bash
npm i adonis-odm
```
```bash
yarn add adonis-odm
```
```bash
pnpm add adonis-odm
```
Next, configure the package by running the following ace command:
```bash
node ace configure adonis-odm
```
The configure command will:
1. Register the MongoDB provider inside the `adonisrc.ts` file
2. Create the `config/odm.ts` configuration file
3. Add environment variables to your `.env` file
4. Set up validation rules for environment variables
## Configuration
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.
```typescript
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
```
### Environment Variables
The following environment variables are used by the MongoDB configuration:
```env
# 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.
### Multiple Connections
You can define multiple NoSQL database connections inside the `config/odm.ts` file and switch between them as needed:
```typescript
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.
## Commands
The package provides several ace commands to help you work with MongoDB ODM:
### Configuration
```bash
# Configure the package (run this after installation)
node ace configure adonis-odm
```
### Model Generation
```bash
# Create a new ODM model
node ace make:odm-model User
```
### Database Seeders
```bash
# 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
```
### Database Operations
```bash
# Test database connection (coming soon)
node ace mongodb:status
# Show database information (coming soon)
node ace mongodb:info
```
## Database Seeders
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.
### Quick Start
#### Creating a Seeder
Generate a new seeder using the ace command:
```bash
# 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`:
```typescript
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,
},
])
}
}
```
#### Running Seeders
```bash
# 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
```
### Advanced Features
#### Environment-Specific Seeders
Control which environments your seeders run in:
```typescript
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' }])
}
}
```
#### Custom Execution Order
Control the order in which seeders execute using static properties:
```typescript
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'] },
])
}
}
```
#### Seeder Dependencies
Define dependencies between seeders to ensure proper execution order:
```typescript
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,
},
])
}
}
```
#### Main Seeders
Create main seeder files (`index.ts` or `main.ts`) that automatically run first:
```typescript
// 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...')
}
}
```
### Working with Different Data Types
#### Embedded Documents
Seed models with embedded documents:
```typescript
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',
},
],
},
])
}
}
```
#### Referenced Relationships
Seed models with relationships:
```typescript
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,
},
])
}
}
}
```
### Connection-Specific Seeding
Use different database connections for different seeders:
```typescript
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:
```bash
# Run all seeders on analytics connection
node ace odm:seed --connection=analytics
```
### Error Handling and Validation
Seeders include comprehensive error handling:
```typescript
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
}
}
}
```
### Best Practices
1. **Use Environment Restrictions**: Prevent test data from appearing in production
2. **Define Clear Dependencies**: Use `static dependencies` for complex seeding scenarios
3. **Check for Existing Data**: Avoid duplicate data by checking before inserting
4. **Use Transactions**: Wrap complex seeding logic in database transactions
5. **Provide Feedback**: Use console.log to show seeding progress
6. **Handle Errors Gracefully**: Implement proper error handling and cleanup
For more detailed examples and advanced usage patterns, see the [seeder documentation](docs/environment-specific-seeders.md) and [examples](examples/seeder_usage.ts).
## Usage
### Database Service
Import the database service to perform transactions and direct database operations:
```typescript
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')
```
### Creating Models
Create a model by extending `BaseModel` and using decorators:
```typescript
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
}
```
### Embedded Documents
The ODM provides full support for embedded documents with type safety and CRUD operations.
#### Defining Embedded Documents
```typescript
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)) || []
}
}
```
#### Creating Records with Embedded Documents
```typescript
// 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,
},
],
})
```
#### Type-Safe Property Access
```typescript
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}`)
})
}
```
#### CRUD Operations on Embedded Documents
```typescript
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()
}
```
#### Querying Embedded Documents
The ODM provides a powerful query builder for embedded documents with full type safety:
```typescript
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')
}
```
#### Loading Embedded Documents with Filtering
Use the `.embed()` method to load embedded documents with type-safe filtering:
```typescript
// 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()
```
### Referenced Relationships
The ODM provides full support for traditional referenced relationships with type-safe decorators and automatic loading.
#### Defining Referenced Relationships
```typescript
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
}
```
#### Loading Referenced Relationships
Use the `.load()` method for type-safe relationship loading:
```typescript
// 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()
```
#### Working with Loaded Relationships
```typescript
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)
}
```
#### Relationship Operations
```typescript
// 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)
```
### Basic CRUD Operations
#### Creating Records
AdonisJS Lucid provides two ways to create records:
**Method 1: Using `.create()` (Recommended)**
```typescript
// 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()`**
```typescript
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**
```typescript
const user = await User.updateOrCreate(
{ email: 'john@example.com' },
{ name: 'John Doe Updated', age: 32 }
)
```
#### Reading Records
```typescript
// 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()
```
#### Updating Records
AdonisJS Lucid provides three ways to update records:
**Method 1: Direct property assignment + save**
```typescript
const user = await User.findOrFail('507f1f77bcf86cd799439011')
user.name = 'Updated Name'
user.age = 31
await user.save()
```
**Method 2: Using `.merge()` + `.save()` (Method chaining)**
```typescript
const user = await User.findOrFail('507f1f77bcf86cd799439011')
await user.merge({ name: 'Updated Name', age: 31 }).save()
```
**Method 3: Using query builder `.update()` (Bulk update)**
```typescript
// Update multiple records at once
await User.query().where('age', '>=', 18).update({ status: 'adult' })
```
#### Deleting Records
AdonisJS Lucid provides two ways to delete records:
**Method 1: Instance delete**
```typescript
const user = await User.findOrFail('507f1f77bcf86cd799439011')
await user.delete()
```
**Method 2: Query builder bulk delete**
```typescript
// Delete multiple records at once
await User.query().where('isVerified', false).delete()
```
### Query Builder
The query builder provides a fluent interface for building complex queries:
#### Basic Queries
```typescript
// 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()
```
#### Query Operators
The ODM supports both MongoDB operators and mathematical symbols:
```typescript
// 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 equal
- `in` - In array
- `nin` - Not in array
- `exists` - Field exists
- `regex` - Regular expression
- `like` - Pattern matching with % wildcards
#### Advanced Queries
```typescript
// 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')
```
#### Pagination
```typescript
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
```
#### Aggregation
```typescript
// 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' })
```
### Column Decorators
The ODM provides several decorators for defining model properties and their behavior.
#### Basic Column
```typescript
@column()
declare name: string
@column({ isPrimary: true })
declare _id: string
```
#### Embedded Columns
```typescript
// 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>
```
#### Date Columns
```typescript
// 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
```
#### Decimal Columns
For precise decimal arithmetic and financial data, use the `@column.decimal()` decorator to properly handle MongoDB's Decimal128 type:
```typescript
@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
#### Custom Serialization
```typescript
@column({
serialize: (value) => value.toUpperCase(),
deserialize: (value) => value.toLowerCase(),
})
declare name: string
```
#### Computed Properties
Computed properties are getter-only properties that are calculated from other model attributes. They are included in JSON serialization but excluded from database operations.
```typescript
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
}
}
```
#### Using Computed Properties
```typescript
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
```
#### Computed Properties with Relationships
```typescript
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
```
#### When to Use @computed() vs Regular Getters
**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
```typescript
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}`
}
}
```
#### Computed Properties Best Practices
1. **Keep computations lightweight** - Avoid heavy calculations in getters
2. **Use appropriate return types** - TypeScript will infer types automatically
3. **Handle null/undefined cases** - Always check for loaded relationships
4. **Use meaningful names** - Make computed property names descriptive
5. **Consider serialization** - Use `serializeAs` to control JSON output
6. **Avoid side effects** - Computed properties should be pure functions
7. **Choose the right pattern** - Use `@computed()` for serialized properties, regular getters for helpers
### Model Lifecycle
Models track their state automatically:
```typescript
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' }
```
### Lifecycle Hooks
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.
#### Available Hooks
```typescript
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`)
}
}
```
#### Hook Execution Order
Hooks are executed in the following order:
**For Create Operations:**
1. `beforeSave`
2. `beforeCreate`
3. Database operation
4. `afterCreate`
5. `afterSave`
**For Update Operations:**
1. `beforeSave`
2. `beforeUpdate`
3. Database operation
4. `afterUpdate`
5. `afterSave`
**For Delete Operations:**
1. `beforeDelete`
2. Database operation
3. `afterDelete`
**For Find Operations:**
1. `beforeFind`
2. Database operation
3. `afterFind`
**For Fetch Operations:**
1. `beforeFetch`
2. Database operation
3. `afterFetch`
#### Aborting Operations
Before hooks can abort operations by returning `false`:
```typescript
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
}
}
}
```
#### Hook Best Practices
1. **Keep hooks lightweight** - Avoid heavy computations in hooks
2. **Use async/await** - Hooks support asynchronous operations
3. **Handle errors gracefully** - Use try/catch blocks for error handling
4. **Return false to abort** - Use return false in before hooks to prevent operations
5. **Use appropriate hook types** - Choose the right hook for your use case
### Database Transactions
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 (Recommended)
Managed transactions automatically handle commit and rollback operations:
```typescript
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())
```
#### Manual Transactions
For more control, you can manually manage transaction lifecycle:
```typescript
// 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)
}
```
#### Model Instance Transactions
You can associate model instances with transactions:
```typescript
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
})
```
#### Query Builder with Transactions
All query builder operations support transactions:
```typescript
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
}
```
#### Transaction Options
You can pass MongoDB-specific transaction options:
```typescript
// 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' },
})
```
#### Error Handling and Rollback
Transactions automatically rollback on errors:
```typescript
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
}
```
#### Best Practices
1. **Use managed transactions** when possible for automatic error handling
2. **Keep transactions short** to minimize lock time
3. **Handle errors appropriately** and always rollback on failure
4. **Use transactions for related operations** that must succeed or fail together
5. **Pass transaction client** to all operations that should be part of the transaction
### Connection Management
You can work with multiple MongoDB connections:
```typescript
// 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')
```
### Error Handling
The ODM provides comprehensive error handling with custom exception types for different scenarios.
#### Exception Types
```typescript
import {
MongoOdmException,
ModelNotFoundException,
ConnectionException,
DatabaseOpera