ORM Patterns
Hysteria ORM supports two distinct patterns for working with your database models:
- Active Record Pattern - Static methods on model classes (recommended)
- Repository Pattern - Using
getModelManager()
for dependency injection
Active Record Pattern
The Active Record pattern is the recommended approach for most applications. It provides a clean, intuitive API where you call static methods directly on your model classes.
How it Works
In the Active Record pattern, your model classes have static methods that handle database operations. These methods internally use a ModelManager
instance but abstract away the complexity.
Example Usage
import { Model, column } from 'hysteria-orm';
class User extends Model {
@column.integer({ primaryKey: true })
declare id: number;
@column()
declare name: string;
@column()
declare email: string;
}
// Active Record Pattern - Static methods on the model
const user = await User.insert({ name: 'John', email: 'john@example.com' });
const users = await User.find({ where: { isActive: true } });
const userById = await User.findOneByPrimaryKey(1);
const query = User.query().where('name', 'like', '%John%').many();
Available Static Methods
Method | Description | Example |
---|---|---|
Model.insert(data) | Insert a new record | User.insert({ name: 'John' }) |
Model.insertMany(data[]) | Insert multiple records | User.insertMany([{ name: 'John' }, { name: 'Jane' }]) |
Model.find(options) | Find multiple records | User.find({ where: { isActive: true } }) |
Model.findOne(options) | Find a single record | User.findOne({ where: { email: 'john@example.com' } }) |
Model.findBy(column, value) | Find by specific column | User.findBy('email', 'john@example.com') |
Model.findOneBy(column, value) | Find one by specific column | User.findOneBy('email', 'john@example.com') |
Model.findOneByPrimaryKey(id) | Find by primary key | User.findOneByPrimaryKey(1) |
Model.update(data, options) | Update records | User.update({ name: 'Johnny' }, { where: { id: 1 } }) |
Model.delete(options) | Delete records | User.delete({ where: { isActive: false } }) |
Model.query() | Get query builder | User.query().where('age', '>', 18).many() |
Model.all() | Get all records | User.all() |
Model.count(options) | Count records | User.count({ where: { isActive: true } }) |
Model.exists(options) | Check if records exist | User.exists({ where: { email: 'john@example.com' } }) |
Benefits
- Clean API: Simple, intuitive method calls
- Type Safety: Full TypeScript support with proper typing
- Model Awareness: Automatic handling of decorators, relations, and hooks
- Less Boilerplate: No need to manage ModelManager instances
- Familiar: Similar to other popular ORMs like TypeORM
- Model Embedding Support: Can be used with embedded models for even cleaner syntax
Model Embedding with Active Record
Model embedding allows you to attach models directly to a SQL data source instance, providing an even cleaner API that combines the benefits of both patterns:
import { sql } from 'hysteria-orm';
import { User } from './models/User';
import { Post } from './models/Post';
// Connect with embedded models
const sqlInstance = await sql.connect({
models: {
user: User,
post: Post,
},
});
// Access models directly through the data source (Active Record + Embedding)
const user = await sqlInstance.user.insert({ name: 'John', email: 'john@example.com' });
const users = await sqlInstance.user.find({ where: { isActive: true } });
const posts = await sqlInstance.post.query().where('published', true).many();
Benefits of Model Embedding
- Cleaner Syntax: Access models as properties on the data source
- Type Safety: Full TypeScript support with
AugmentedSqlDataSource<T>
- Connection Context: Models are tied to specific connections
- Prisma-like Experience: Similar to Prisma's client model access
- All Active Record Benefits: Still get all the benefits of the Active Record pattern
When to Use Model Embedding
Model embedding is perfect when you want:
- Cleaner API:
sqlInstance.user.insert()
vsUser.insert()
- Connection-specific Models: Different models for different connections
- Service Architecture: Pass the data source instance to services
- Testing: Easy to mock the entire data source with embedded models
// Service that accepts embedded models
class UserService {
constructor(private sql: AugmentedSqlDataSource<{ user: typeof User }>) {}
async createUser(userData: Partial<User>) {
return await this.sql.user.insert(userData);
}
async findActiveUsers() {
return await this.sql.user.find({ where: { isActive: true } });
}
}
// Usage
const sqlInstance = await sql.connect({ models: { user: User } });
const userService = new UserService(sqlInstance);
const users = await userService.findActiveUsers();
Repository Pattern (getModelManager)
The Repository pattern provides more control and is useful for dependency injection, testing, or when you need explicit control over the data source connection.
How it Works
Instead of using static methods, you get a ModelManager
instance from the data source and use its methods directly.
Example Usage
import { sql, Model, column } from 'hysteria-orm';
class User extends Model {
@column.integer({ primaryKey: true })
declare id: number;
@column()
declare name: string;
@column()
declare email: string;
}
// Repository Pattern - Using getModelManager
const userManager = sql.getModelManager(User);
const user = await userManager.insert({ name: 'John', email: 'john@example.com' });
const users = await userManager.find({ where: { isActive: true } });
const userById = await userManager.findOneByPrimaryKey(1);
const query = userManager.query().where('name', 'like', '%John%').many();
When to Use Repository Pattern
The Repository pattern is useful when you need:
- Dependency Injection: Pass the manager to services or controllers
- Testing: Mock the manager for unit tests
- Multiple Connections: Use different data sources for the same model
- Explicit Control: Want to manage the data source connection explicitly
Example with Dependency Injection
// Service class that accepts a ModelManager
class UserService {
constructor(private userManager: ModelManager<User>) {}
async createUser(userData: Partial<User>) {
return await this.userManager.insert(userData);
}
async findActiveUsers() {
return await this.userManager.find({
where: { isActive: true }
});
}
}
// Usage
const userManager = sql.getModelManager(User);
const userService = new UserService(userManager);
const users = await userService.findActiveUsers();
Example with Custom Connection
// Using a secondary connection
const secondarySql = await sql.connectToSecondarySource({
type: 'postgres',
host: 'read-replica.example.com',
// ... other config
});
const readOnlyUserManager = secondarySql.getModelManager(User);
const users = await readOnlyUserManager.find({ where: { isActive: true } });
Pattern Comparison
Feature | Active Record | Active Record + Embedding | Repository Pattern |
---|---|---|---|
API Simplicity | ✅ Very simple | ✅ Cleanest syntax | ⚠️ More verbose |
Type Safety | ✅ Full support | ✅ Full support | ✅ Full support |
Dependency Injection | ❌ Not suitable | ✅ Perfect for services | ✅ Perfect |
Testing | ⚠️ Harder to mock | ✅ Easy to mock | ✅ Easy to mock |
Multiple Connections | ⚠️ Limited | ✅ Full control | ✅ Full control |
Model Awareness | ✅ Automatic | ✅ Automatic | ✅ Automatic |
Hooks & Relations | ✅ Full support | ✅ Full support | ✅ Full support |
Learning Curve | ✅ Easy | ✅ Easy | ⚠️ Moderate |
Connection Context | ❌ Global only | ✅ Per-connection | ✅ Per-connection |
Best Practices
Use Active Record When:
- Building typical web applications
- You want the simplest API
- You're using the default database connection
- You don't need complex dependency injection
Use Active Record + Model Embedding When:
- Building service-oriented applications
- You want the cleanest possible API (
sqlInstance.user.insert()
) - You need connection-specific models
- You want dependency injection with clean syntax
- You're building microservices or modular applications
- You need easy testing with mockable data sources
Use Repository Pattern When:
- Building enterprise applications with complex architecture
- You need fine-grained control over ModelManager instances
- You're working with multiple database connections
- You want explicit control over data source management
- You need to pass individual managers to different services
Migration Between Patterns
You can easily switch between patterns as your application grows:
// Start with Active Record
const user = await User.insert({ name: 'John' });
// Upgrade to Active Record + Model Embedding for cleaner services
const sqlInstance = await sql.connect({ models: { user: User } });
class UserService {
constructor(private sql: AugmentedSqlDataSource<{ user: typeof User }>) {}
async createUser(data: Partial<User>) {
return await this.sql.user.insert(data);
}
}
// Or use Repository Pattern for fine-grained control
class UserService {
constructor(private userManager: ModelManager<User>) {}
async createUser(data: Partial<User>) {
return await this.userManager.insert(data);
}
}
All patterns use the same underlying ModelManager
class, so the functionality is identical. The choice is primarily about API design and architectural preferences.
Recommended Progression
- Start with Active Record for simple applications
- Upgrade to Active Record + Model Embedding when you need services and dependency injection
- Use Repository Pattern only when you need fine-grained control over individual managers
Next: Defining Models