adonis-odm
Version:
A comprehensive MongoDB ODM for AdonisJS with Lucid-style API, type-safe relationships, embedded documents, and transaction support
348 lines (347 loc) • 10.6 kB
JavaScript
import { CamelCaseNamingStrategy, } from '../naming_strategy/naming_strategy.js';
import { ModelRegistry } from './model_registry.js';
import { AttributeManager } from './attribute_manager.js';
import { SerializationManager } from './serialization_manager.js';
import { PersistenceManager } from './persistence_manager.js';
import { StaticQueryMethods } from './static_query_methods.js';
/**
* Symbol to store model metadata
*/
export const MODEL_METADATA = Symbol('model_metadata');
/**
* Base Model class for MongoDB ODM
*/
export class BaseModel {
/**
* Naming strategy for the model
* Defaults to CamelCaseNamingStrategy which converts camelCase to snake_case for serialization
*/
static namingStrategy = new CamelCaseNamingStrategy();
/**
* Indicates if the model instance exists in the database
*/
$isPersisted = false;
/**
* Indicates if the model instance is local and not persisted
*/
$isLocal = true;
/**
* Tracks modified properties
*/
$dirty = {};
/**
* Original values before modifications
*/
$original = {};
/**
* MongoDB document ID
*/
_id;
/**
* Transaction client for this model instance
*/
$trx;
/**
* Attribute manager instance
*/
$attributeManager;
/**
* Serialization manager instance
*/
$serializationManager;
/**
* Persistence manager instance
*/
$persistenceManager;
constructor(attributes = {}) {
// Initialize managers
this.$attributeManager = new AttributeManager(this);
this.$serializationManager = new SerializationManager(this);
this.$persistenceManager = new PersistenceManager(this);
this.fill(attributes);
// For new models, mark all filled attributes as dirty so hooks can detect them
if (!this.$isPersisted) {
for (const [key, value] of Object.entries(attributes)) {
const metadata = this.constructor.getMetadata();
const columnOptions = metadata.columns.get(key);
// Skip reference fields and computed properties
if (!columnOptions?.isReference && !columnOptions?.isComputed) {
this.$dirty[key] = value;
}
}
}
this.initializeRelationshipProxies();
// Auto-register the model class in the registry
ModelRegistry.register(this.constructor);
}
/**
* Get model metadata
*/
static getMetadata() {
if (!this[MODEL_METADATA]) {
;
this[MODEL_METADATA] = {
columns: new Map(),
primaryKey: '_id',
tableName: undefined,
};
}
return this[MODEL_METADATA];
}
/**
* Register a model in the global registry
*/
static register() {
ModelRegistry.register(this);
}
/**
* Get a model class from the registry
*/
static getModelClass(modelName) {
return ModelRegistry.get(modelName);
}
/**
* Check if a model is registered
*/
static hasModelClass(modelName) {
return ModelRegistry.has(modelName);
}
/**
* Get all registered models
*/
static getAllModels() {
return ModelRegistry.getAll();
}
/**
* Clear the model registry (useful for testing)
*/
static clearRegistry() {
ModelRegistry.clear();
}
/**
* Associate this model instance with a transaction
*/
useTransaction(trx) {
this.$trx = trx;
return this;
}
/**
* Static collection name property (Lucid pattern)
* Override this in your model to specify a custom collection name
*/
static collection;
/**
* Get collection name for the model
*
* Priority order:
* 1. Static collection property (recommended Lucid pattern)
* 2. Metadata tableName (backward compatibility)
* 3. Auto-generated from class name
*/
static getCollectionName() {
// First priority: static collection property (Lucid pattern)
if (this.collection) {
return this.collection;
}
// Second priority: metadata tableName (for backward compatibility)
const metadata = this.getMetadata();
if (metadata.tableName) {
return metadata.tableName;
}
// Third priority: auto-generate from class name
// Convert class name to snake_case and pluralize
const className = this.name;
const snakeCase = className
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
// Better pluralization logic
// Handle common patterns like "User" -> "users", "AdminUser" -> "admin_users"
const words = snakeCase.split('_');
// Helper function to pluralize a word
const pluralize = (word) => {
// Handle words ending in 'y' preceded by a consonant
if (word.endsWith('y') && word.length > 1) {
const beforeY = word[word.length - 2];
// If the letter before 'y' is a vowel, just add 's' (e.g., "key" -> "keys")
// If it's a consonant, replace 'y' with 'ies' (e.g., "city" -> "cities")
if ('aeiou'.includes(beforeY)) {
return word + 's';
}
else {
return word.slice(0, -1) + 'ies';
}
}
else if (word.endsWith('s') ||
word.endsWith('sh') ||
word.endsWith('ch') ||
word.endsWith('x') ||
word.endsWith('z')) {
return word + 'es';
}
else {
return word + 's';
}
};
// Pluralize only the last word (the main entity)
// Examples: "AdminUser" -> "admin_users", "UserProfile" -> "user_profiles"
if (words.length > 0) {
words[words.length - 1] = pluralize(words[words.length - 1]);
}
return words.join('_');
}
/**
* Get connection name for the model
*/
static getConnection() {
return 'mongodb'; // Default connection name
}
/**
* Create a new query builder instance with type-safe relationship loading
*/
static query(options) {
return StaticQueryMethods.query(this, options);
}
/**
* Find a document by its ID
*/
static async find(id, options) {
return StaticQueryMethods.find(this, id, options);
}
/**
* Find a document by its ID or throw an exception
*/
static async findOrFail(id, options) {
return StaticQueryMethods.findOrFail(this, id, options);
}
/**
* Find a document by a specific field
*/
static async findBy(field, value) {
return StaticQueryMethods.findBy(this, field, value);
}
/**
* Find a document by a specific field or throw an exception
*/
static async findByOrFail(field, value) {
return StaticQueryMethods.findByOrFail(this, field, value);
}
/**
* Get the first document
*/
static async first() {
return StaticQueryMethods.first(this);
}
/**
* Get the first document or throw an exception
*/
static async firstOrFail() {
return StaticQueryMethods.firstOrFail(this);
}
/**
* Get all documents
*/
static async all() {
return StaticQueryMethods.all(this);
}
/**
* Create a new document
*/
static async create(attributes, options) {
return StaticQueryMethods.create(this, attributes, options);
}
/**
* Create multiple documents
*/
static async createMany(attributesArray) {
return StaticQueryMethods.createMany(this, attributesArray);
}
/**
* Update or create a document
*/
static async updateOrCreate(searchPayload, persistencePayload) {
return StaticQueryMethods.updateOrCreate(this, searchPayload, persistencePayload);
}
/**
* Fill the model with attributes
*/
fill(attributes) {
this.$attributeManager.fill(attributes);
return this;
}
/**
* Merge new attributes into the model
*/
merge(attributes) {
this.$attributeManager.merge(attributes);
return this;
}
/**
* Save the model to the database
*/
async save() {
await this.$persistenceManager.save();
return this;
}
/**
* Delete the model from the database
*/
async delete() {
return this.$persistenceManager.delete();
}
/**
* Get an attribute value
*/
getAttribute(key) {
return this.$attributeManager.getAttribute(key);
}
/**
* Set an attribute value
*/
setAttribute(key, value) {
this.$attributeManager.setAttribute(key, value);
}
/**
* Hydrate the model from a MongoDB document
* Handles conversion from database column names (snake_case) back to model properties (camelCase)
*/
hydrateFromDocument(document) {
this.$serializationManager.hydrateFromDocument(document);
}
/**
* Convert the model to a plain object for database storage
* Following AdonisJS Lucid naming strategy (camelCase -> snake_case by default)
*/
toDocument() {
return this.$serializationManager.toDocument();
}
/**
* Convert the model to a plain object for JSON serialization (API responses)
* This includes computed properties and properly serializes relationships
* Following AdonisJS Lucid naming strategy (camelCase -> snake_case by default)
*/
toJSON() {
return this.$serializationManager.toJSON();
}
/**
* Get dirty attributes for the model
* Returns attributes with database column names (snake_case) for database operations
*/
getDirtyAttributes() {
return this.$attributeManager.getDirtyAttributes();
}
/**
* Sync the original values with current values
*/
syncOriginal() {
this.$attributeManager.syncOriginal();
}
/**
* Initialize relationship proxies for Lucid-style property access
* Note: Relationship proxies are now created directly by the decorators
*/
initializeRelationshipProxies() {
// Relationship proxies are automatically created by the @hasOne, @hasMany, @belongsTo decorators
// No manual initialization needed
}
}