Ensemble ORM - Comprehensive Technical Documentation

Executive Summary

Ensemble is Orchestr's ActiveRecord ORM implementation, serving as the TypeScript equivalent to Laravel's Eloquent ORM. It provides a fluent, expressive interface for database interactions while maintaining full type safety through TypeScript. Ensemble combines the elegance of Laravel's Eloquent with the power of TypeScript's type system, enabling developers familiar with Laravel to seamlessly transition to the Node.js ecosystem.

ActiveRecord Pattern

Intuitive model definitions with database mapping

Fluent Query Builder

Method chaining for readable database queries

Type-Safe Attributes

Automatic casting with TypeScript support

Timestamp Management

Automatic created_at and updated_at handling

Soft Deletes

Non-destructive deletion with recovery options

Mass Assignment Protection

Secure bulk assignment with fillable/guarded

Rich Collections

Powerful methods for result manipulation

Accessors & Mutators

Computed properties and data transformation

Development Status

Current Status: Core features are production-ready. Model relationships are currently under development and will be available in a future release.

Architecture Overview

Design Philosophy

Ensemble implements the ActiveRecord pattern, where each model class represents a database table and each instance represents a row in that table. This approach provides an object-oriented interface to database operations while maintaining a clear separation between data access logic and business logic.

Core Components

The Ensemble ORM consists of five primary components:

  1. Ensemble.ts - The abstract base class providing ActiveRecord functionality
  2. EnsembleBuilder.ts - Fluent query builder for constructing database queries
  3. EnsembleCollection.ts - Rich collection class for manipulating query results
  4. SoftDeletes.ts - Mixin interface for soft delete functionality
  5. Concerns/ - Reusable traits for common model behaviors

Component Interactions

┌─────────────────────────────────────────────────────────────┐
│                     Your Model Class                         │
│                   (extends Ensemble)                         │
└────────────────────┬────────────────────────────────────────┘
                     │
                     ├─► query() ──► EnsembleBuilder
                     │                     │
                     │                     ├─► get() ──► EnsembleCollection
                     │                     ├─► first() ──► Model Instance
                     │                     └─► paginate() ──► Paginated Results
                     │
                     ├─► Concerns:
                     │   ├─► HasAttributes (casts, accessors, mutators)
                     │   └─► HasTimestamps (created_at, updated_at)
                     │
                     └─► SoftDeletes (deleted_at handling)

Type System Integration

Ensemble leverages TypeScript's type system extensively:

// Generic constraint ensures type safety
class EnsembleBuilder<T extends Ensemble> {
  async get(): Promise<EnsembleCollection<T>> { /* ... */ }
  async first(): Promise<T | null> { /* ... */ }
  async find(id: any): Promise<T | null> { /* ... */ }
}

// Collections maintain type information
class EnsembleCollection<T extends Ensemble> extends Array<T> {
  pluck<K extends keyof T>(key: K): Array<T[K]> { /* ... */ }
}

Defining Models

Basic Model Structure

Every Ensemble model extends the Ensemble base class and defines configuration properties to control behavior:

import { Ensemble } from '@orchestr-sh/orchestr';

class User extends Ensemble {
  // Table configuration
  protected table = 'users';
  protected primaryKey = 'id';
  protected incrementing = true;
  protected keyType = 'int';

  // Mass assignment
  protected fillable = ['name', 'email', 'password'];
  protected guarded = ['id', 'created_at', 'updated_at'];

  // Serialization
  protected hidden = ['password', 'remember_token'];
  protected visible: string[] = [];
  protected appends: string[] = [];

  // Type casting
  protected casts = {
    email_verified_at: 'datetime' as const,
    is_admin: 'boolean' as const,
    metadata: 'json' as const,
  };

  // Timestamps
  public timestamps = true;
  protected static CREATED_AT = 'created_at';
  protected static UPDATED_AT = 'updated_at';
}

Configuration Properties

Table Configuration

PropertyTypeDefaultDescription
tablestringPluralized class nameDatabase table name
primaryKeystring'id'Primary key column name
incrementingbooleantrueWhether primary key auto-increments
keyTypestring'int'Primary key data type
class Post extends Ensemble {
  protected table = 'blog_posts';           // Custom table name
  protected primaryKey = 'post_id';          // Custom primary key
  protected incrementing = true;             // Auto-incrementing key
  protected keyType = 'int';                 // Integer key type
}

class ApiToken extends Ensemble {
  protected table = 'api_tokens';
  protected primaryKey = 'token';
  protected incrementing = false;            // UUID or custom key
  protected keyType = 'string';              // String-based key
}

Mass Assignment Protection

Ensemble provides two mechanisms to control which attributes can be mass-assigned:

class User extends Ensemble {
  // Allow only these attributes
  protected fillable = ['name', 'email', 'password'];

  // Alternative: Block only these attributes
  protected guarded = ['id', 'is_admin', 'created_at'];
}

// Usage
const user = await User.create({
  name: 'John Doe',
  email: 'john@example.com',
  password: 'secret',
  is_admin: true  // Will be ignored if using fillable approach
});

Best Practice: Use fillable for strict control (whitelist), or guarded for flexibility (blacklist). Never use both simultaneously.

Serialization Control

Control which attributes appear in JSON responses:

class User extends Ensemble {
  // Hide these attributes when serializing
  protected hidden = ['password', 'remember_token', 'two_factor_secret'];

  // Only show these attributes (overrides hidden)
  protected visible = ['id', 'name', 'email'];

  // Append computed attributes
  protected appends = ['full_name', 'avatar_url'];
}

const user = await User.find(1);
const json = user.toJSON(); // { id: 1, name: "John", email: "john@..." }

Type Definitions

For full type safety, define model attribute interfaces:

interface UserAttributes {
  id: number;
  name: string;
  email: string;
  password: string;
  email_verified_at: Date | null;
  is_admin: boolean;
  metadata: Record<string, any>;
  created_at: Date;
  updated_at: Date;
}

class User extends Ensemble implements UserAttributes {
  public id!: number;
  public name!: string;
  public email!: string;
  public password!: string;
  public email_verified_at!: Date | null;
  public is_admin!: boolean;
  public metadata!: Record<string, any>;
  public created_at!: Date;
  public updated_at!: Date;

  protected table = 'users';
  protected fillable = ['name', 'email', 'password'];
  protected casts = {
    email_verified_at: 'datetime' as const,
    is_admin: 'boolean' as const,
    metadata: 'json' as const,
  };
}

Model Queries

Query Builder Basics

The EnsembleBuilder extends Orchestr's base QueryBuilder with model-specific functionality. Access it through the static query() method:

// Get query builder instance
const queryBuilder = User.query();

// Chain query methods
const users = await User.query()
  .where('is_active', true)
  .orderBy('created_at', 'desc')
  .limit(10)
  .get();

Retrieving Models

Get All Records

// Retrieve all users
const users = await User.all();

// Equivalent to:
const users = await User.query().get();

Find by Primary Key

// Find single user by ID
const user = await User.find(1);

// Returns null if not found
if (!user) {
  console.log('User not found');
}

// Find or throw exception
const user = await User.findOrFail(1);
// Throws error if not found

// Find multiple users
const users = await User.findMany([1, 2, 3]);

First Record

// Get first matching record
const user = await User.query()
  .where('email', 'john@example.com')
  .first();

// Returns null if no match

Query Constraints

Where Clauses

// Simple where
const users = await User.query()
  .where('is_admin', true)
  .get();

// Multiple conditions
const users = await User.query()
  .where('is_active', true)
  .where('email_verified_at', '!=', null)
  .get();

// Or conditions
const users = await User.query()
  .where('role', 'admin')
  .orWhere('role', 'moderator')
  .get();

// Where in
const users = await User.query()
  .whereIn('id', [1, 2, 3, 4])
  .get();

// Where not in
const users = await User.query()
  .whereNotIn('status', ['banned', 'suspended'])
  .get();

// Where null
const users = await User.query()
  .whereNull('email_verified_at')
  .get();

// Where not null
const users = await User.query()
  .whereNotNull('deleted_at')
  .get();

Ordering

// Order by single column
const users = await User.query()
  .orderBy('created_at', 'desc')
  .get();

// Multiple order by
const users = await User.query()
  .orderBy('is_admin', 'desc')
  .orderBy('name', 'asc')
  .get();

Limiting Results

// Limit
const users = await User.query()
  .limit(10)
  .get();

// Offset
const users = await User.query()
  .offset(20)
  .limit(10)
  .get();

// Take (alias for limit)
const users = await User.query()
  .take(5)
  .get();

// Skip (alias for offset)
const users = await User.query()
  .skip(10)
  .take(5)
  .get();

Pagination

// Paginate results
const result = await User.query()
  .where('is_active', true)
  .paginate(15, 1); // 15 per page, page 1

console.log(result.data);        // Current page data
console.log(result.total);       // Total records
console.log(result.perPage);     // Items per page
console.log(result.currentPage); // Current page
console.log(result.lastPage);    // Last page number

Chunking Results

Process large result sets in batches:

// Process in chunks of 100
await User.query().chunk(100, async (users) => {
  for (const user of users) {
    await processUser(user);
  }
});

Aggregates

// Count
const count = await User.query().count();

// Count with conditions
const activeCount = await User.query()
  .where('is_active', true)
  .count();

// Max
const maxId = await User.query().max('id');

// Min
const minId = await User.query().min('id');

// Average
const avgAge = await User.query().avg('age');

// Sum
const totalBalance = await User.query().sum('balance');

Eager Loading (Planned)

// Note: Relationships are currently under development
// Future syntax:
const users = await User.query()
  .with(['posts', 'comments'])
  .get();

CRUD Operations

Creating Models

Using the Constructor and Save

// Create new instance
const user = new User();
user.name = 'John Doe';
user.email = 'john@example.com';
user.password = await hashPassword('secret');

// Save to database
await user.save();

console.log(user.id);           // Auto-incremented ID
console.log(user.exists);       // true
console.log(user.wasRecentlyCreated); // true

Using Create (Mass Assignment)

// Create and save in one step
const user = await User.create({
  name: 'Jane Doe',
  email: 'jane@example.com',
  password: await hashPassword('secret'),
});

console.log(user.id); // Saved with auto-generated ID

Update or Create

// Find by attributes or create new
const user = await User.updateOrCreate(
  // Search criteria
  { email: 'john@example.com' },

  // Values to set
  {
    name: 'John Doe',
    password: await hashPassword('newsecret'),
  }
);

// If user exists: updates name and password
// If user doesn't exist: creates new user with all attributes

Reading Models

// Find by primary key
const user = await User.find(1);

// Find or fail
try {
  const user = await User.findOrFail(999);
} catch (error) {
  console.log('User not found');
}

// Find many
const users = await User.findMany([1, 2, 3]);

// Get all
const users = await User.all();

// Get with query
const users = await User.query()
  .where('is_active', true)
  .get();

Updating Models

Update Single Model

// Find, modify, save
const user = await User.find(1);
if (user) {
  user.name = 'Updated Name';
  user.email = 'updated@example.com';
  await user.save();
}

// Check if model was modified
console.log(user.isDirty());        // false after save
console.log(user.getDirty());       // {} after save

// Before save
user.name = 'Another Update';
console.log(user.isDirty());        // true
console.log(user.isDirty('name'));  // true
console.log(user.getDirty());       // { name: 'Another Update' }

Mass Update

// Update all matching records
const affectedRows = await User.query()
  .where('is_active', false)
  .update({
    is_active: true,
    activated_at: new Date(),
  });

console.log(`Updated ${affectedRows} users`);

Fill Method

const user = await User.find(1);

// Fill multiple attributes (respects fillable)
user.fill({
  name: 'New Name',
  email: 'new@example.com',
  is_admin: true, // Ignored if not in fillable
});

await user.save();

Deleting Models

Delete Single Model

const user = await User.find(1);
if (user) {
  await user.delete();
}

// Check deletion status
console.log(user.exists); // false after deletion

Mass Delete

// Delete all matching records
const deletedCount = await User.query()
  .where('last_login', '<', oneYearAgo)
  .delete();

console.log(`Deleted ${deletedCount} inactive users`);

Refreshing Models

const user = await User.find(1);

// Reload from database
await user.refresh();

// Useful after background updates
const user = await User.find(1);
// ... some other process updates the user ...
await user.refresh(); // Get latest data

Tracking Changes

const user = await User.find(1);

// Original values
console.log(user.original); // { id: 1, name: "John", ... }

// Modify
user.name = 'Jane';

// Check changes
console.log(user.isDirty());           // true
console.log(user.isDirty('name'));     // true
console.log(user.isDirty('email'));    // false
console.log(user.getDirty());          // { name: "Jane" }

// Get original value
console.log(user.getAttribute('name'));     // "Jane" (current)
console.log(user.original['name']);         // "John" (original)

// Sync original
user.syncOriginal();
console.log(user.isDirty()); // false

Collections

Overview

EnsembleCollection<T> extends native JavaScript arrays with powerful methods for manipulating query results. Collections provide a fluent interface for filtering, transforming, and aggregating model instances.

const users = await User.query()
  .where('is_active', true)
  .get();

// users is an EnsembleCollection<User>
console.log(users instanceof Array);              // true
console.log(users instanceof EnsembleCollection); // true

Creating Collections

// From query
const users = await User.query().get();

// From array
const collection = new EnsembleCollection([user1, user2, user3]);

// Empty collection
const empty = new EnsembleCollection([]);

Retrieval Methods

// Get all items
const items = collection.all();

// First item
const first = collection.first();

// First matching condition
const admin = collection.first(user => user.is_admin);

// Last item
const last = collection.last();

// Last matching condition
const lastAdmin = collection.last(user => user.is_admin);

// Find by primary key
const user = collection.find(5);

// Find by callback
const moderator = collection.find(user => user.role === 'moderator');

Filtering Methods

// Filter collection
const admins = collection.filter(user => user.is_admin);

// Reject (inverse filter)
const nonAdmins = collection.reject(user => user.is_admin);

// Where in
const specificUsers = collection.whereIn('id', [1, 2, 3]);

// Where not in
const excludedUsers = collection.whereNotIn('id', [1, 2, 3]);

// Only specific models
const subset = collection.only([1, 2, 3]); // By primary keys

// Except specific models
const excluded = collection.except([1, 2, 3]); // Exclude by primary keys

// Unique items
const unique = collection.unique();

// Unique by attribute
const uniqueByEmail = collection.unique('email');

Transformation Methods

// Map to array
const names = collection.mapIntoCollection(user => user.name);

// Pluck attribute
const emails = collection.pluck('email');
// Returns: ['john@example.com', 'jane@example.com', ...]

// To plain array
const array = collection.toArray();
// Returns: [{ id: 1, name: "John", ... }, ...]

// To JSON
const json = collection.toJSON();
// Returns: JSON string

// Group by attribute
const grouped = collection.groupBy('role');
// Returns: { admin: [user1, user2], user: [user3, user4] }

// Sort by attribute
const sorted = collection.sortBy('created_at');

// Sort descending
const sortedDesc = collection.sortByDesc('created_at');

// Sort by callback
const sorted = collection.sortBy(user => user.name.toLowerCase());

Aggregation Methods

// Count
const total = collection.count();

// Sum
const totalBalance = collection.sum('balance');

// Average
const avgAge = collection.avg('age');

// Min
const youngest = collection.min('age');

// Max
const oldest = collection.max('age');

Pagination and Chunking

// Take first n items
const first10 = collection.take(10);

// Skip first n items
const skipped = collection.skip(5);

// Combine skip and take
const paginated = collection.skip(10).take(5); // Items 11-15

// Chunk into smaller collections
const chunks = collection.chunkModels(100);
// Returns: [EnsembleCollection<User>, EnsembleCollection<User>, ...]

for (const chunk of chunks) {
  await processBatch(chunk);
}

Utility Methods

// Check if empty
if (collection.isEmpty()) {
  console.log('No results');
}

// Check if not empty
if (collection.isNotEmpty()) {
  console.log(`Found ${collection.count()} results`);
}

// Contains (by key)
const hasUser5 = collection.contains(5);

// Contains (by callback)
const hasAdmin = collection.contains(user => user.is_admin);

// Merge with another collection
const merged = collection.merge(otherUsers);

// Get model keys
const ids = collection.modelKeys();
// Returns: [1, 2, 3, 4, ...]

// Eager load relationships (planned)
await collection.load(['posts', 'comments']);

Chaining Methods

const result = await User.all()
  .filter(user => user.is_active)
  .reject(user => user.is_banned)
  .sortBy('created_at')
  .take(10)
  .pluck('email');

console.log(result); // Array of 10 email addresses

Relationships

Current Status

Model relationships are currently under development. The following documentation represents the planned API based on Laravel's Eloquent relationship patterns.

Planned Relationship Types

// One to One
class User extends Ensemble {
  profile() {
    return this.hasOne(Profile, 'user_id');
  }
}

// One to Many
class Post extends Ensemble {
  comments() {
    return this.hasMany(Comment, 'post_id');
  }
}

// Belongs To
class Comment extends Ensemble {
  post() {
    return this.belongsTo(Post, 'post_id');
  }
}

// Many to Many
class User extends Ensemble {
  roles() {
    return this.belongsToMany(Role, 'user_roles', 'user_id', 'role_id');
  }
}

// Has Many Through
class Country extends Ensemble {
  posts() {
    return this.hasManyThrough(Post, User, 'country_id', 'user_id');
  }
}

Planned Usage

// Eager loading
const users = await User.query()
  .with(['profile', 'posts.comments'])
  .get();

// Lazy loading
const user = await User.find(1);
const profile = await user.profile().first();
const posts = await user.posts().get();

// Relationship queries
const recentPosts = await user.posts()
  .where('published', true)
  .orderBy('created_at', 'desc')
  .get();

// Create related models
const post = await user.posts().create({
  title: 'New Post',
  content: 'Content here...',
});

// Attach many-to-many
await user.roles().attach([1, 2, 3]);

// Detach
await user.roles().detach([2]);

// Sync
await user.roles().sync([1, 3, 4]);

Soft Deletes

Overview

Soft deletes allow models to be marked as deleted without actually removing them from the database. This is useful for maintaining data integrity, audit trails, and allowing data recovery.

Interface Definition

interface SoftDeletes {
  forceDeleting: boolean;
  forceDelete(): Promise<boolean>;
  restore(): Promise<void>;
  trashed(): boolean;
  getDeletedAtColumn(): string;
  getQualifiedDeletedAtColumn(): string;
}

const DELETED_AT = 'deleted_at';

Implementation Status

The soft delete mixin is currently a placeholder interface. The full implementation is planned for a future release. The interface is available for type checking:

import { SoftDeletes, DELETED_AT } from '@orchestr-sh/orchestr';

class User extends Ensemble implements SoftDeletes {
  public forceDeleting: boolean = false;
  protected static DELETED_AT = DELETED_AT;

  async forceDelete(): Promise<boolean> {
    // Implementation pending
    throw new Error('Not implemented');
  }

  async restore(): Promise<void> {
    // Implementation pending
    throw new Error('Not implemented');
  }

  trashed(): boolean {
    return this.getAttribute(DELETED_AT) !== null;
  }

  getDeletedAtColumn(): string {
    return DELETED_AT;
  }

  getQualifiedDeletedAtColumn(): string {
    return `${this.getTable()}.${DELETED_AT}`;
  }
}

Planned Usage

// Soft delete
const user = await User.find(1);
await user.delete(); // Sets deleted_at timestamp

// Check if deleted
if (user.trashed()) {
  console.log('User is soft deleted');
}

// Restore
await user.restore(); // Clears deleted_at

// Force delete (permanent)
await user.forceDelete(); // Removes from database

// Query with trashed
const allUsers = await User.query()
  .withTrashed()
  .get(); // Includes soft deleted

const onlyTrashed = await User.query()
  .onlyTrashed()
  .get(); // Only soft deleted

// Default behavior excludes trashed
const activeUsers = await User.query()
  .get(); // Excludes soft deleted

Database Schema

CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  email VARCHAR(255) NOT NULL,
  deleted_at TIMESTAMP NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- Soft deleted users
SELECT * FROM users WHERE deleted_at IS NOT NULL;

-- Active users
SELECT * FROM users WHERE deleted_at IS NULL;

Timestamps

Overview

The HasTimestamps concern automatically manages created_at and updated_at timestamps on models.

Interface Definition

interface HasTimestamps {
  timestamps: boolean;

  updateTimestamps(): void;
  setCreatedAt(value: Date): void;
  setUpdatedAt(value: Date): void;
  getCreatedAtColumn(): string;
  getUpdatedAtColumn(): string;
  usesTimestamps(): boolean;
}

const CREATED_AT = 'created_at';
const UPDATED_AT = 'updated_at';

Configuration

class User extends Ensemble {
  // Enable timestamps (default: true)
  public timestamps = true;

  // Custom column names
  protected static CREATED_AT = 'created_at';
  protected static UPDATED_AT = 'updated_at';
}

class Log extends Ensemble {
  // Disable timestamps
  public timestamps = false;
}

class LegacyModel extends Ensemble {
  // Custom timestamp column names
  protected static CREATED_AT = 'creation_date';
  protected static UPDATED_AT = 'modification_date';
}

Automatic Behavior

// Create - sets created_at and updated_at
const user = await User.create({
  name: 'John Doe',
  email: 'john@example.com',
});

console.log(user.created_at); // Current timestamp
console.log(user.updated_at); // Current timestamp

// Update - updates updated_at only
user.name = 'Jane Doe';
await user.save();

console.log(user.created_at); // Unchanged
console.log(user.updated_at); // New timestamp

Manual Control

const user = new User();
user.name = 'John Doe';

// Set timestamps manually
user.setCreatedAt(new Date('2024-01-01'));
user.setUpdatedAt(new Date('2024-01-15'));

await user.save();

Implementation Details

The updateTimestamps() method is called automatically during save operations:

// Internal implementation in Ensemble class
protected async performInsert(): Promise<void> {
  if (this.usesTimestamps()) {
    this.updateTimestamps();
  }
  // ... insert logic
}

protected async performUpdate(): Promise<void> {
  if (this.usesTimestamps()) {
    this.setUpdatedAt(new Date());
  }
  // ... update logic
}

Database Schema

CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  email VARCHAR(255) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

Attribute Casting

Overview

The HasAttributes concern provides automatic type casting for model attributes, ensuring data consistency and type safety.

Cast Types

type CastType =
  | 'int' | 'integer'
  | 'real' | 'float' | 'double'
  | 'decimal'
  | 'string'
  | 'bool' | 'boolean'
  | 'object'
  | 'array'
  | 'json'
  | 'collection'
  | 'date' | 'datetime' | 'timestamp';

Defining Casts

class User extends Ensemble {
  protected casts = {
    // Numeric casts
    id: 'int' as const,
    balance: 'float' as const,
    score: 'decimal' as const,

    // Boolean cast
    is_admin: 'boolean' as const,
    is_verified: 'bool' as const,

    // Date casts
    email_verified_at: 'datetime' as const,
    last_login: 'date' as const,
    created_at: 'timestamp' as const,

    // JSON casts
    metadata: 'json' as const,
    settings: 'object' as const,
    tags: 'array' as const,

    // Collection cast
    permissions: 'collection' as const,
  };
}

Cast Behavior

Numeric Casts

class Product extends Ensemble {
  protected casts = {
    price: 'float' as const,
    stock: 'int' as const,
  };
}

const product = await Product.find(1);
console.log(typeof product.price); // "number"
console.log(typeof product.stock); // "number"

// String to number conversion
product.price = '19.99';  // Stored as string in DB
console.log(product.price); // 19.99 (number)

Boolean Casts

class User extends Ensemble {
  protected casts = {
    is_admin: 'boolean' as const,
  };
}

const user = await User.find(1);
console.log(typeof user.is_admin); // "boolean"

// Database values converted to boolean
// 1, '1', 'true', 'yes', 'on' -> true
// 0, '0', 'false', 'no', 'off', null -> false

Date Casts

class Post extends Ensemble {
  protected casts = {
    published_at: 'datetime' as const,
  };
}

const post = await Post.find(1);
console.log(post.published_at instanceof Date); // true

// Set date
post.published_at = new Date();
await post.save();

// Also accepts strings
post.published_at = '2024-01-15T10:30:00Z';
console.log(post.published_at instanceof Date); // true

JSON Casts

class User extends Ensemble {
  protected casts = {
    metadata: 'json' as const,
    settings: 'object' as const,
  };
}

const user = await User.find(1);

// Automatic JSON parse/stringify
user.metadata = { theme: 'dark', language: 'en' };
await user.save(); // Stored as JSON string in DB

const retrieved = await User.find(1);
console.log(retrieved.metadata); // { theme: 'dark', language: 'en' }
console.log(typeof retrieved.metadata); // "object"

Array Casts

class Post extends Ensemble {
  protected casts = {
    tags: 'array' as const,
  };
}

const post = await Post.find(1);

post.tags = ['typescript', 'nodejs', 'orm'];
await post.save(); // Stored as JSON array

const retrieved = await Post.find(1);
console.log(Array.isArray(retrieved.tags)); // true
console.log(retrieved.tags); // ['typescript', 'nodejs', 'orm']

Custom Accessors and Mutators

Accessors (Getters)

class User extends Ensemble {
  protected appends = ['full_name', 'avatar_url'];

  // Accessor: computed on read
  protected getFullNameAttribute(): string {
    return `${this.getAttribute('first_name')} ${this.getAttribute('last_name')}`;
  }

  protected getAvatarUrlAttribute(): string {
    const email = this.getAttribute('email');
    const hash = md5(email.toLowerCase());
    return `https://www.gravatar.com/avatar/${hash}`;
  }
}

const user = await User.find(1);
console.log(user.full_name);  // "John Doe" (computed)
console.log(user.avatar_url); // "https://..." (computed)

// Include in JSON
const json = user.toJSON();
// { id: 1, first_name: "John", ..., full_name: "John Doe", avatar_url: "..." }

Mutators (Setters)

class User extends Ensemble {
  // Mutator: transform on write
  protected setPasswordAttribute(value: string): void {
    this.attributes['password'] = hashPassword(value);
  }

  protected setEmailAttribute(value: string): void {
    this.attributes['email'] = value.toLowerCase().trim();
  }
}

const user = new User();
user.password = 'PlainTextPassword';  // Automatically hashed
user.email = '  JOHN@EXAMPLE.COM  ';  // Normalized

await user.save();
console.log(user.password); // Hashed value
console.log(user.email);    // "john@example.com"

Naming Convention

Accessors and mutators follow a strict naming pattern:

// Accessor: get{AttributeName}Attribute
protected getFullNameAttribute(): string { /* ... */ }

// Mutator: set{AttributeName}Attribute
protected setPasswordAttribute(value: string): void { /* ... */ }

// Attribute name uses StudlyCase:
// full_name -> FullName
// avatar_url -> AvatarUrl
// is_admin -> IsAdmin

Concerns and Traits

Overview

Concerns are reusable interfaces that provide common functionality to models. They follow the trait pattern from Laravel, allowing composition of behaviors.

HasAttributes Concern

interface HasAttributes {
  attributes: Record<string, any>;
  original: Record<string, any>;
  casts: Record<string, CastType>;
  hidden: string[];
  visible: string[];
  appends: string[];
}

Purpose: Manages model attributes, casting, visibility, and serialization.

Usage:

class User extends Ensemble implements HasAttributes {
  protected casts = {
    is_admin: 'boolean' as const,
    metadata: 'json' as const,
  };

  protected hidden = ['password'];
  protected visible: string[] = [];
  protected appends = ['full_name'];
}

HasTimestamps Concern

interface HasTimestamps {
  timestamps: boolean;

  updateTimestamps(): void;
  setCreatedAt(value: Date): void;
  setUpdatedAt(value: Date): void;
  getCreatedAtColumn(): string;
  getUpdatedAtColumn(): string;
  usesTimestamps(): boolean;
}

Purpose: Automatically manages created_at and updated_at timestamps.

Usage:

class User extends Ensemble implements HasTimestamps {
  public timestamps = true;

  protected static CREATED_AT = 'created_at';
  protected static UPDATED_AT = 'updated_at';
}

SoftDeletes Concern

interface SoftDeletes {
  forceDeleting: boolean;

  forceDelete(): Promise<boolean>;
  restore(): Promise<void>;
  trashed(): boolean;
  getDeletedAtColumn(): string;
  getQualifiedDeletedAtColumn(): string;
}

Purpose: Enables soft deletion functionality.

Usage:

class User extends Ensemble implements SoftDeletes {
  public forceDeleting = false;

  protected static DELETED_AT = 'deleted_at';

  // Implement interface methods
  async forceDelete(): Promise<boolean> { /* ... */ }
  async restore(): Promise<void> { /* ... */ }
  trashed(): boolean { /* ... */ }
  getDeletedAtColumn(): string { /* ... */ }
  getQualifiedDeletedAtColumn(): string { /* ... */ }
}

Combining Concerns

class User extends Ensemble
  implements HasAttributes, HasTimestamps, SoftDeletes {

  // HasAttributes
  protected casts = { is_admin: 'boolean' as const };
  protected hidden = ['password'];
  protected appends = ['full_name'];

  // HasTimestamps
  public timestamps = true;
  protected static CREATED_AT = 'created_at';
  protected static UPDATED_AT = 'updated_at';

  // SoftDeletes
  public forceDeleting = false;
  protected static DELETED_AT = 'deleted_at';

  // Implement all interface methods...
}

Conclusion

Ensemble ORM provides a powerful, type-safe ActiveRecord implementation for TypeScript applications. With its Laravel-inspired API, comprehensive feature set, and tight TypeScript integration, it offers an elegant solution for database interactions in the Orchestr ecosystem.

While the core functionality is production-ready, relationships and soft deletes are planned for future releases. The current implementation provides robust CRUD operations, collections, attribute casting, and timestamp management.

Additional Resources