The easiest way to interact with OData APIs ever - ORM-like simplicity with OData v4 power
npm install odata-active-record-coreimport { ActiveRecord, EntityNamespaceManager } from 'odata-active-record-core';
// Create a namespace for your entities
const namespace = EntityNamespaceManager.createNamespace('my-app');
// Define your entity
const User = namespace.registerEntity('User', {
name: { type: 'string', nullable: false },
email: { type: 'string', nullable: false },
age: { type: 'number', nullable: true },
isActive: { type: 'boolean', nullable: false, defaultValue: true },
createdAt: { type: 'date', nullable: false }
});
// Use it like an ORM!
const users = await User
.where('age', 'gt', 25)
.where('isActive', 'eq', true)
.select(['name', 'email'])
.orderBy('name', 'asc')
.limit(10)
.find();
console.log(users.data); // Array of users- π ORM-like API - Familiar Active Record pattern
- π Automatic Date Handling - Any date format, automatically parsed
- π‘οΈ Fault Tolerant - Graceful error handling with actionable feedback
- ποΈ Multi-Provider Support - MongoDB, SQLite, HTTP OData
- π Namespace Isolation - Complete separation between different data sources
- β‘ Astro Integration - Seamless SSR/SSG and API routes
- π Schema Validation - Automatic drift detection and warnings
- π― TypeScript First - Full type safety and IntelliSense
# Core package
npm install odata-active-record-core
# With Astro integration
npm install odata-active-record-astro
# With specific providers
npm install mongodb better-sqlite3import { ActiveRecord, EntityNamespaceManager } from 'odata-active-record-core';
// Create a namespace
const namespace = EntityNamespaceManager.createNamespace('blog');
// Define entities
const Post = namespace.registerEntity('Post', {
title: { type: 'string', nullable: false },
content: { type: 'string', nullable: false },
publishedAt: { type: 'date', nullable: true },
isPublished: { type: 'boolean', nullable: false, defaultValue: false },
viewCount: { type: 'number', nullable: false, defaultValue: 0 }
});
const Author = namespace.registerEntity('Author', {
name: { type: 'string', nullable: false },
email: { type: 'string', nullable: false },
bio: { type: 'string', nullable: true }
});// Create
const newPost = await Post.create({
title: 'My First Post',
content: 'Hello, world!',
publishedAt: '2024-01-15', // Any date format works!
isPublished: true
});
console.log(newPost.data); // { id: 1, title: 'My First Post', ... }
// Read
const posts = await Post
.where('isPublished', 'eq', true)
.where('publishedAt', 'gt', '2024-01-01')
.orderBy('publishedAt', 'desc')
.limit(5)
.find();
// Update
const updatedPost = await Post
.where('id', 'eq', 1)
.update({ viewCount: 42 });
// Delete
await Post.where('id', 'eq', 1).delete();// Complex filtering
const popularPosts = await Post
.where('viewCount', 'gt', 1000)
.where('isPublished', 'eq', true)
.where('title', 'contains', 'tutorial')
.select(['title', 'viewCount', 'publishedAt'])
.orderBy('viewCount', 'desc')
.limit(10)
.find();
// Cross-entity queries (within namespace)
const postsWithAuthors = await Post
.expand('author')
.where('author.name', 'contains', 'John')
.find();
// Aggregations
const stats = await Post
.aggregate([
{ $group: { _id: '$isPublished', count: { $sum: 1 } } }
])
.execute();import { MongoDBProvider } from 'odata-active-record-core';
const mongoProvider = new MongoDBProvider(
'mongodb://localhost:27017',
'my-database'
);
await mongoProvider.connect();
// Use with namespace
const namespace = EntityNamespaceManager.createNamespace('mongo-app');
namespace.setProvider(mongoProvider);
const User = namespace.registerEntity('users', {
username: { type: 'string', nullable: false },
email: { type: 'string', nullable: false },
profile: { type: 'json', nullable: true }
});import { SQLiteProvider } from 'odata-active-record-core';
const sqliteProvider = new SQLiteProvider('./data.db');
await sqliteProvider.connect();
// Auto-create tables
await sqliteProvider.createTable('users', {
fields: {
username: { type: 'string', nullable: false },
email: { type: 'string', nullable: false },
created_at: { type: 'date', nullable: false }
}
});import { HTTPODataProvider } from 'odata-active-record-core';
const odataProvider = new HTTPODataProvider('https://services.odata.org/V4/Northwind/Northwind.svc');
// Set authentication if needed
odataProvider.setAuthHeaders({
'Authorization': 'Bearer your-token'
});
await odataProvider.connect();
// Use existing OData service
const Products = namespace.registerEntity('Products');
const products = await Products
.where('UnitPrice', 'gt', 50)
.select(['ProductName', 'UnitPrice'])
.find();// src/pages/api/users.ts
import { AstroODataIntegration } from 'odata-active-record-astro';
export const GET = AstroODataIntegration.createApiHandler({
entity: 'User',
namespace: 'my-app',
operations: {
list: true,
get: true,
create: true,
update: true,
delete: true
}
});---
// src/pages/blog.astro
import { AstroODataIntegration } from 'odata-active-record-astro';
const posts = await AstroODataIntegration.getData({
entity: 'Post',
namespace: 'blog',
query: {
where: { isPublished: true },
orderBy: { publishedAt: 'desc' },
limit: 10
}
});
---
<html>
<head><title>Blog</title></head>
<body>
{posts.data.map(post => (
<article>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
</body>
</html>// src/pages/api/edge/users.ts
export const GET = AstroODataIntegration.createEdgeHandler({
entity: 'User',
namespace: 'my-app',
cache: {
ttl: 300, // 5 minutes
strategy: 'stale-while-revalidate'
}
});Any date format is automatically parsed:
// All of these work automatically:
await Post.create({
title: 'Post with dates',
publishedAt: '2024-01-15', // YYYY-MM-DD
updatedAt: '01/15/2024', // MM/DD/YYYY
created: '15-01-2024', // DD-MM-YYYY
scheduled: '2024-01-15T10:30:00Z', // ISO string
relative: 'yesterday', // Relative dates
natural: '2 days ago', // Natural language
timestamp: 1705312800000 // Unix timestamp
});User-friendly error messages with actionable feedback:
const result = await User.create({
email: 'invalid-email', // Invalid email
age: 'not-a-number' // Invalid age
});
if (!result.success) {
console.log(result.errors);
// [
// {
// code: 'VALIDATION_ERROR',
// message: 'Invalid email format',
// field: 'email',
// suggestions: ['Use a valid email format like user@example.com']
// },
// {
// code: 'TYPE_MISMATCH',
// message: 'Expected number, got string',
// field: 'age',
// suggestions: ['Provide a numeric value for age']
// }
// ]
}Complete separation between different data sources:
// E-commerce namespace
const ecommerce = EntityNamespaceManager.createNamespace('ecommerce');
const Product = ecommerce.registerEntity('Product', { /* ... */ });
const Order = ecommerce.registerEntity('Order', { /* ... */ });
// Analytics namespace (completely separate)
const analytics = EntityNamespaceManager.createNamespace('analytics');
const PageView = analytics.registerEntity('PageView', { /* ... */ });
const UserEvent = analytics.registerEntity('UserEvent', { /* ... */ });
// Cross-entity queries within namespace
const ordersWithProducts = await Order
.expand('product')
.where('product.category', 'eq', 'electronics')
.find();
// No cross-namespace queries (maintains isolation)
// This won't work: Order.expand('pageView') - different namespaces!Automatic drift detection and warnings:
// Schema drift detection
const result = await User.create({
name: 'John',
email: 'john@example.com',
newField: 'value' // Field not in schema
});
if (result.warnings) {
console.log(result.warnings);
// [
// {
// code: 'SCHEMA_DRIFT',
// message: 'Unknown field "newField" detected',
// field: 'newField',
// suggestions: ['Add this field to the schema or remove it from the data']
// }
// ]
}import { describe, it, expect } from 'vitest';
import { ActiveRecord, EntityNamespaceManager } from 'odata-active-record-core';
describe('User Entity', () => {
it('should create a user', async () => {
const namespace = EntityNamespaceManager.createNamespace('test');
const User = namespace.registerEntity('User', {
name: { type: 'string', nullable: false },
email: { type: 'string', nullable: false }
});
const result = await User.create({
name: 'John Doe',
email: 'john@example.com'
});
expect(result.success).toBe(true);
expect(result.data.name).toBe('John Doe');
});
});where(field, operator, value)- Add filter conditionselect(fields)- Select specific fieldsorderBy(field, direction)- Sort resultslimit(count)- Limit number of resultsoffset(count)- Skip resultsexpand(relation)- Include related entitiesfind()- Execute query and return resultsfindOne()- Execute query and return single resultcount()- Get count of matching recordscreate(data)- Create new recordupdate(data)- Update existing recorddelete()- Delete matching records
eq- Equalne- Not equalgt- Greater thange- Greater than or equallt- Less thanle- Less than or equalcontains- Contains substringstartswith- Starts withendswith- Ends within- In arraynotin- Not in array
- Fork the repository
- Create a feature branch
- Write tests first (TDD)
- Implement the feature
- Ensure all tests pass
- Submit a pull request
MIT License - see LICENSE for details.
- π Documentation
- π Issues
- π¬ Discussions
Made with β€οΈ for the OData community