Skip to content

BenQoder/eloquent-orm

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

41 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Eloquent ORM - TypeScript Read-Only ORM

A powerful, type-safe, read-only ORM for TypeScript that allows you to access Laravel databases from Node.js environments. Built with Zod for runtime validation and designed specifically for data querying and retrieval operations.

🎯 Purpose & Use Case

Laravel Database Access from Node.js: This ORM is specifically designed for scenarios where you have a Laravel backend but need to access the same database from Node.js applications (microservices, analytics tools, reporting services, etc.).

Perfect for:

  • Node.js microservices that need to read from Laravel databases
  • Analytics and reporting tools built in Node.js
  • API gateways that aggregate data from Laravel databases
  • Background processing services
  • Real-time dashboards and monitoring tools

Read-Only by Design: This ORM is intentionally designed for read operations only. It does not support insert, update, delete, or DDL operations. This design choice ensures:

  • Safety: Prevents accidental data mutations (write operations should go through Laravel)
  • Performance: Optimized specifically for querying
  • Clarity: Clear separation between read and write operations
  • Security: Runtime guards reject non-SELECT SQL statements
  • Laravel Compatibility: Maintains your Laravel app as the single source of truth for data mutations
  • Migration Management: Database schema changes and migrations remain in your Laravel backend
  • Business Logic: All write operations, validations, and business logic stay in Laravel

🏗️ Architecture Pattern

This ORM enables a clean separation of concerns in multi-service architectures:

┌─────────────────────┐    ┌─────────────────────┐
│   Laravel Backend   │    │  Node.js Services   │
│                     │    │                     │
│  ✅ Write Operations│    │  ✅ Read Operations │
│  ✅ Migrations      │    │  ✅ Analytics       │
│  ✅ Business Logic  │    │  ✅ Reporting       │
│  ✅ Validations     │    │  ✅ API Gateways    │
│  ✅ Seeders         │    │  ✅ Dashboards      │
│                     │    │                     │
└─────────┬───────────┘    └─────────┬───────────┘
          │                          │
          └──────────┬─────────────────┘
                     │
           ┌─────────▼─────────┐
           │   Shared Database │
           │                   │
           │  📊 MySQL/Postgres│
           │  📊 Same Schema   │
           └───────────────────┘

✨ Key Features

  • 🔒 Read-Only Safety: Runtime guards prevent mutation operations
  • 🏷️ Full TypeScript Support: Complete type inference with loaded relations
  • Zod Integration: Runtime validation with schema-based typing
  • 🔗 Rich Relationships: belongsTo, hasMany, hasOne, morphMany, belongsToMany with chainable queries
  • 🔍 Advanced Querying: Eager loading, constraints, aggregates, soft deletes
  • Automatic Relationship Autoloading: Optional global or per-collection lazy eager loading
  • 🎛️ Query Builder: Fluent, chainable query interface
  • 📊 Aggregations: Built-in sum, count, avg, min, max support
  • 🍣 Sushi Support: In-memory array data source (no database needed)
  • 🐛 Debug Logging: Built-in query logging with execution details
  • 🚀 Performance: Optimized for read-heavy workloads

📦 Installation

npm install @benqoder/eloquent-orm

🏗️ Prerequisites

This package is designed to work with existing Laravel applications. You'll need:

  • Laravel Backend: An existing Laravel application with database models
  • Shared Database: Access to the same database used by your Laravel app
  • Database Connection: A Node.js database connection to your Laravel database
  • Laravel Schema Knowledge: Understanding of your Laravel model structure and relationships

🚀 Quick Start

1. Initialize Connection

import Eloquent from '@benqoder/eloquent-orm';
import mysql from 'mysql2/promise';

// Connect to your Laravel database
const connection = await mysql.createConnection({
	host: process.env.DB_HOST,
	user: process.env.DB_USERNAME,
	password: process.env.DB_PASSWORD,
	database: process.env.DB_DATABASE, // Same database as your Laravel app
	port: parseInt(process.env.DB_PORT || '3306'),
});

// Initialize Eloquent with the Laravel database connection
await Eloquent.init(connection);

2. Define Models (Mirror Your Laravel Models)

Create TypeScript models that mirror your existing Laravel Eloquent models:

import Eloquent from '@benqoder/eloquent-orm';
import { z } from 'zod';

// This should mirror your Laravel User model
class User extends Eloquent {
	protected static table = 'users'; // Same table as Laravel

	// Define Zod schema matching your Laravel migration/model
	static schema = z.object({
		id: z.number().int().optional(),
		name: z.string(),
		email: z.string().email(),
		email_verified_at: z.union([z.string(), z.date()]).nullable().optional(),
		created_at: z.union([z.string(), z.date()]).nullable().optional(),
		updated_at: z.union([z.string(), z.date()]).nullable().optional(),
	});

	// Define relation types matching your Laravel relationships
	relationsTypes!: {
		posts: Post[];
		profile: Profile;
	};

	// Define relationships (same as your Laravel model)
	posts() {
		return this.hasMany(Post, 'user_id');
	}

	profile() {
		return this.hasOne(Profile, 'user_id');
	}
}

// Use declaration merging for automatic schema typing
interface User extends z.infer<typeof User.schema> {}

export default User;

3. Query Data

// Basic queries
const users = await User.query().get();
const user = await User.query().find(1);

// With relationships
const usersWithPosts = await User.query().with('posts').get();
const userWithProfile = await User.query().with(['posts', 'profile']).first();

// Advanced querying
const activeUsers = await User.query()
	.where('status', 'active')
	.whereNotNull('email_verified_at')
	.orderBy('created_at', 'desc')
	.limit(10)
	.get();

🐛 Debug Logging

Enable detailed query logging to monitor database operations:

import Eloquent from '@benqoder/eloquent-orm';

// Enable debug logging with default console logger
Eloquent.enableDebug();

// Or provide custom logger
Eloquent.enableDebug((message, data) => {
	console.log(`[SQL Debug] ${message}`, data);
});

// Disable debug logging
Eloquent.disableDebug();

Debug Output Example:

[DEBUG] Executing query {
  "sql": "SELECT * FROM users WHERE status = ? LIMIT 10",
  "params": ["active"],
  "hasUnions": false
}

[DEBUG] Query completed {
  "resultCount": 5,
  "hasRelations": true,
  "relations": ["posts", "profile"]
}

📚 Complete API Documentation

Query Builder Methods

Basic Queries

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

// Get single record
const user = await User.query().first();
const user = await User.query().find(1);

// Check existence
const exists = await User.query().exists();

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

Where Clauses

// Basic where
User.query().where('name', 'John');
User.query().where('age', '>', 18);
User.query().where('status', 'in', ['active', 'pending']);

// Multiple conditions
User.query().where('status', 'active').where('age', '>=', 18);

// Or conditions
User.query().where('role', 'admin').orWhere('role', 'moderator');

// Null checks
User.query().whereNull('deleted_at');
User.query().whereNotNull('email_verified_at');

// Between
User.query().whereBetween('age', [18, 65]);

// In/Not In
User.query().whereIn('status', ['active', 'pending']);
User.query().whereNotIn('role', ['banned', 'suspended']);

// Like patterns
User.query().where('name', 'like', '%john%');

// Raw conditions
User.query().whereRaw('YEAR(created_at) = ?', [2023]);

Ordering & Limiting

// Order by
User.query().orderBy('created_at', 'desc');
User.query().orderBy('name'); // defaults to 'asc'

// Multiple order columns
User.query().orderBy('status', 'asc').orderBy('created_at', 'desc');

// Random order
User.query().inRandomOrder();

// Limit and offset
User.query().limit(10);
User.query().offset(20);
User.query().limit(10).offset(20); // pagination

Grouping & Aggregates

// Group by
User.query().groupBy('status');

// Having
User.query().groupBy('status').having('count(*)', '>', 5);

// Aggregates
const total = await User.query().count();
const sum = await User.query().sum('points');
const avg = await User.query().avg('age');
const min = await User.query().min('created_at');
const max = await User.query().max('updated_at');

Relationships

Defining Relationships

Relationships use a class-based architecture where methods return Relation instances that support chainable queries:

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

	// One-to-Many
	posts() {
		return this.hasMany(Post, 'user_id');
	}

	// Inverse One-to-Many
	category() {
		return this.belongsTo(Category, 'category_id');
	}

	// Many-to-Many
	roles() {
		return this.belongsToMany(Role, 'user_roles', 'user_id', 'role_id');
	}

	// Polymorphic One-to-Many
	comments() {
		return this.morphMany(Comment, 'commentable', 'commentable_type', 'commentable_id');
	}

	// One of Many
	latestPost() {
		return this.hasOneOfMany(Post, 'user_id', 'created_at', 'max');
	}

	// Through relationships
	postComments() {
		return this.through('posts').has('comments');
	}
}

// Chainable queries on relationships
const user = await User.query().find(1);

// Add constraints directly on relationships
const publishedPosts = await user.posts()
	.where('published', true)
	.orderBy('created_at', 'desc')
	.get();

const latestPost = await user.posts()
	.orderBy('created_at', 'desc')
	.first();

const hasAnyPosts = await user.posts().exists();

Eager Loading

// Basic eager loading
const users = await User.query().with('posts').get();

// Multiple relations
const users = await User.query().with(['posts', 'profile']).get();

// Nested relations
const users = await User.query().with('posts.comments').get();

// Column selection
const users = await User.query().with('posts:id,title,created_at').get();

// Conditional eager loading
const users = await User.query()
	.with({
		posts: (query) => query.where('published', true),
	})
	.get();

// Constrained eager loading
const users = await User.query()
	.withWhereHas('posts', (query) => {
		query.where('published', true);
	})
	.get();

Automatic Relationship Autoloading

You can enable Laravel-like automatic relationship autoloading. When enabled, accessing an unloaded relation triggers a lazy eager load. If the instance belongs to a collection with autoloading enabled, the relation is loaded for the entire collection in one batched query.

Global enable:

import Eloquent from '@benqoder/eloquent-orm';
Eloquent.automaticallyEagerLoadRelationships();

Per-collection:

const users = await User.query().get();
users.withRelationshipAutoloading();

// Accessing users[0].posts will load posts for all users in the collection
console.log(users[0].posts.length);

Note: Property access is synchronous in JavaScript; eager loading still occurs asynchronously. Prefer calling a load step before reading values in performance-critical paths.

Collection-wide Loading with loadForAll

To explicitly load relations across the entire collection from any instance:

const products = await Product.query().limit(5).get();

// Load for all items in the collection (only missing relations are fetched)
await products[0].loadForAll('business');

// Multiple or nested relations
await products[0].loadForAll('business', 'business.orders', 'business.orders.items');

// Tuple/array literal also works (use `as const` for best typing)
await products[0].loadForAll(['business', 'categories'] as const);

// Now you can safely access loaded values
const ids = products.map((p) => p.business?.id ?? null);

Constraints and column selection:

// Constrain relations with callbacks
await products[0].loadForAll({
	business: (q) => q.where('status', 'active'),
	'business.orders': (q) => q.where('total', '>', 100),
});

// Select specific columns
await products[0].loadForAll({ business: ['id', 'name'] });

Typing tips:

  • Single relation returns the instance augmented with that relation key:
const p = products[0];
const withBiz = await p.loadForAll('business');
// withBiz: Product & { business: Business }
  • Multiple keys via variadic args:
const augmented = await p.loadForAll('business', 'categories');
// augmented: Product & { business: Business; categories: ProductCategory[] }
  • Arrays should be declared as const tuples to preserve literal keys:
const rels = ['business', 'categories'] as const;
const augmented2 = await p.loadForAll(rels);

The loader batches queries and skips already-loaded relations to avoid redundant fetches.

Lazy Loading

const user = await User.query().first();

// Load relations after fetching
await user.load('posts');
await user.load(['posts', 'profile']);

// Load missing relations only
await user.loadMissing('posts');

Relationship Queries

// Query relationship existence
User.query().has('posts');
User.query().has('posts', '>', 5);
User.query().doesntHave('posts');

// Query with relationship constraints
User.query().whereHas('posts', (query) => {
	query.where('published', true);
});

User.query().whereDoesntHave('posts', (query) => {
	query.where('published', false);
});

// Relationship aggregates
User.query().withCount('posts');
User.query().withSum('posts', 'views');
User.query().withAvg('posts', 'rating');
User.query().withMax('posts', 'created_at');
User.query().withMin('posts', 'created_at');

Sushi - In-Memory Data Source

Use in-memory arrays as a data source without needing a database connection:

// Static data - no database needed!
class Country extends Eloquent {
  protected static rows = [
    { id: 1, code: 'US', name: 'United States', population: 331000000 },
    { id: 2, code: 'CA', name: 'Canada', population: 38000000 },
    { id: 3, code: 'GB', name: 'United Kingdom', population: 67000000 },
  ];
}

// Query just like a database model
const countries = await Country.query()
  .where('population', '>', 50000000)
  .orderBy('name')
  .get();

Or fetch from an API:

class ApiCountry extends Eloquent {
  static async getRows(): Promise<Record<string, any>[]> {
    const response = await fetch('https://api.example.com/countries');
    return response.json();
  }
}

// Query API data with familiar Eloquent syntax
const large = await ApiCountry.query()
  .where('population', '>', 100000000)
  .get();

See Sushi Documentation for more details.

Advanced Features

Soft Deletes

class User extends Eloquent {
	static softDeletes = true; // Enable soft delete support
}

// Query including soft deleted
User.query().withTrashed().get();

// Query only soft deleted
User.query().onlyTrashed().get();

// Query without soft deleted (default)
User.query().withoutTrashed().get();

Scopes and Conditional Queries

// Conditional queries
User.query().when(searchTerm, (query, term) => {
	query.where('name', 'like', `%${term}%`);
});

// Default fallback
User.query().when(
	false,
	(query) => query.where('active', true),
	(query) => query.where('status', 'pending') // default
);

Raw Queries and Expressions

// Raw expressions
User.query().select(User.raw('COUNT(*) as total'));

// Raw where conditions
User.query().whereRaw('YEAR(created_at) = ?', [2023]);

// Raw having conditions
User.query().havingRaw('COUNT(*) > ?', [5]);

Query Unions

const activeUsers = User.query().where('status', 'active');
const premiumUsers = User.query().where('plan', 'premium');

const combinedUsers = activeUsers.union(premiumUsers).get();

Chunking Large Results

// Process in chunks to manage memory
await User.query().chunk(1000, (users) => {
	users.forEach((user) => {
		// Process each user
		console.log(user.name);
	});
});

// Async chunk processing
await User.query().chunkAsync(1000, async (users) => {
	await processUsers(users);
});

Schema Integration

Zod Schema Definition

import { z } from 'zod';

class Product extends Eloquent {
	static schema = z.object({
		id: z.number().int().optional(),
		name: z.string(),
		price: z.string(), // Stored as string for precision
		weight: z.number().nullable().optional(),
		available: z.number().nullable().optional(),
		description: z.string().nullable().optional(),
		created_at: z.union([z.string(), z.date()]).nullable().optional(),
		updated_at: z.union([z.string(), z.date()]).nullable().optional(),
	});
}

// Declaration merging for automatic typing
interface Product extends z.infer<typeof Product.schema> {}

Runtime Validation

The ORM automatically validates data against your Zod schemas:

// Data is automatically validated and typed
const products = await Product.query().get();
// products[0].price is typed as string
// products[0].weight is typed as number | null | undefined

TypeScript Integration

Automatic Type Inference

// Basic queries are properly typed
const users: User[] = await User.query().get();
const user: User | null = await User.query().first();

// Relations are properly typed
const usersWithPosts = await User.query().with('posts').get();
// usersWithPosts[0].posts is typed as Post[]

// Relation queries return correct types
const posts: Post[] = await user.posts().get();
const profile: Profile | null = await user.profile().first();

Relation Type Definitions

class User extends Eloquent {
	// Define relation return types for TypeScript
	relationsTypes!: {
		posts: Post[];
		profile: Profile;
		roles: Role[];
		comments: Comment[];
	};
}

// With relations automatically include the loaded types
const user = await User.query().with('posts').first();
// user.posts is properly typed as Post[]

🔧 Configuration

Model Configuration

Models support various configuration options to customize their behavior:

class User extends Eloquent {
	// Database Configuration
	protected static table = 'users'; // Table name
	protected static hidden: string[] = ['password']; // Hidden in JSON output
	protected static with: string[] = ['profile']; // Default eager loading
	static softDeletes = true; // Enable soft delete support
	static morphClass = 'App\\Models\\User'; // For polymorphic relations

	// Schema & Validation
	static schema?: z.ZodTypeAny; // Zod validation schema

	// Type Casting
	protected static casts: Record<string, string> = {
		created_at: 'datetime',
		updated_at: 'datetime',
		amount: 'integer',
		price: 'string', // Stored as string in DB
		is_active: 'boolean',
	};

	// Relationship Type Definitions (for TypeScript)
	relationsTypes!: {
		posts: Post[];
		profile: Profile;
		roles: Role[];
	};
}

Available Configuration Options

Property Type Description Example
table string Database table name 'users'
hidden string[] Fields to hide in JSON output ['password', 'token']
with string[] Default eager loading ['profile', 'posts']
softDeletes boolean Enable soft delete support true
morphClass string Polymorphic type identifier 'App\\Models\\User'
schema z.ZodTypeAny Zod validation schema z.object({...})
casts Record<string, string> Type casting rules {'created_at': 'datetime'}
relationsTypes object TypeScript relation definitions {posts: Post[]}

Schema Definition Guidelines

// ✅ Good - Complete schema with proper types
static schema = z.object({
	id: z.number().int().optional(),
	name: z.string().min(1).max(255),
	email: z.string().email(),
	age: z.number().int().min(0).max(150).nullable().optional(),
	created_at: z.union([z.string(), z.date()]).nullable().optional(),
	updated_at: z.union([z.string(), z.date()]).nullable().optional(),
});

// ❌ Bad - Incomplete or incorrect schema
static schema = z.object({
	id: z.any(),  // Too loose
	name: z.string(),  // No validation
	age: z.number(),  // Doesn't handle null/undefined
});

Cast Types Available

Cast Database Type TypeScript Type Example
'integer' INT number 123
'float' FLOAT/DECIMAL number 123.45
'string' VARCHAR/TEXT string "Hello"
'boolean' TINYINT(1) boolean true
'datetime' DATETIME/TIMESTAMP Date new Date()
'array' JSON any[] [1, 2, 3]
'json' JSON object {key: 'value'}

Connection Management

import Eloquent from '@benqoder/eloquent-orm';

// Initialize with existing connection
await Eloquent.init(mysqlConnection);

// The ORM does not create connections - use your existing setup

⚠️ Important Limitations

Read-Only Design

This ORM is intentionally read-only. The following operations are NOT supported:

// ❌ These will throw runtime errors
User.create({name: 'John'});     // No create
user.save();                     // No save
user.delete();                   // No delete
User.query().update({...});      // No update
User.query().insert({...});      // No insert

Laravel Backend Responsibilities

All write operations and schema management should happen in your Laravel backend:

  • Migrations: Use Laravel's migration system (php artisan migrate)
  • Create/Update/Delete: Use Laravel controllers, jobs, or artisan commands
  • Schema Changes: Add new tables/columns through Laravel migrations
  • Business Logic: Keep validation rules and business logic in Laravel
  • Seeders: Use Laravel seeders for populating test data

SQL Restrictions

  • Only SELECT statements are allowed
  • Raw SQL is validated to prevent mutations
  • DDL operations are blocked
  • Transactions are not supported (read-only)

🎨 Examples

E-commerce Product Catalog

// Product model
class Product extends Eloquent {
	static schema = z.object({
		id: z.number().int().optional(),
		business_id: z.number().int(),
		name: z.string(),
		price: z.string(),
		available: z.number().nullable().optional(),
	});

	relationsTypes!: {
		business: Business;
		categories: ProductCategory[];
		medias: Media[];
	};

	business() {
		return this.belongsTo(Business, 'business_id');
	}

	categories() {
		return this.belongsToMany(ProductCategory, 'category_product');
	}
}

// Query examples
const products = await Product.query()
	.with(['business', 'categories'])
	.where('available', '>', 0)
	.whereHas('business', (query) => {
		query.where('status', 'active');
	})
	.orderBy('created_at', 'desc')
	.limit(20)
	.get();

const product = products[0];
console.log(product.name); // string
console.log(product.business.name); // string
console.log(product.categories.length); // number

Blog System

class Post extends Eloquent {
	static schema = z.object({
		id: z.number().int().optional(),
		user_id: z.number().int(),
		title: z.string(),
		content: z.string(),
		published: z.boolean(),
		published_at: z.date().nullable().optional(),
	});

	relationsTypes!: {
		author: User;
		comments: Comment[];
		tags: Tag[];
	};

	author() {
		return this.belongsTo(User, 'user_id');
	}

	comments() {
		return this.hasMany(Comment, 'post_id');
	}

	tags() {
		return this.belongsToMany(Tag, 'post_tags');
	}
}

// Advanced querying
const publishedPosts = await Post.query()
	.with(['author', 'comments', 'tags'])
	.where('published', true)
	.whereNotNull('published_at')
	.withCount('comments')
	.orderBy('published_at', 'desc')
	.chunk(100, (posts) => {
		posts.forEach((post) => {
			console.log(`${post.title} by ${post.author.name}`);
			console.log(`${post.comments_count} comments`);
		});
	});

🤝 Contributing

This is a specialized read-only ORM. When contributing:

  1. Maintain the read-only design principle
  2. Add runtime guards for any new SQL-generating methods
  3. Ensure full TypeScript support
  4. Add Zod integration for new features
  5. Include comprehensive tests

🎯 Explicit Relationship Typing

For maximum type safety, you can explicitly specify relationship shapes:

const orders = await Order.query().with(['business.owner', 'cart.items.product']).get<{
	business: Business & { owner: User };
	cart: Cart & { items: (CartItem & { product: Product })[] };
}>();

This provides complete type inference for nested relationships and works with all query methods: get(), first(), firstOrFail(), find(), findOrFail(), and instance methods: load(), loadMissing(), loadForAll().

📄 License

MIT License - see LICENSE file for details.

🙏 Acknowledgments

Inspired by Laravel's Eloquent ORM, adapted for TypeScript with a read-only focus and enhanced type safety through Zod integration.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published