adonis-odm
Version:
A comprehensive MongoDB ODM for AdonisJS with Lucid-style API, type-safe relationships, embedded documents, and transaction support
692 lines (691 loc) • 30.6 kB
JavaScript
import { ModelNotFoundException, DatabaseOperationException, HookExecutionException, RelationshipException, } from '../exceptions/index.js';
import { BaseModel } from '../base_model/base_model.js';
/**
* QueryExecutor - Handles query execution and result processing
*
* This class encapsulates all the logic for executing MongoDB queries
* and processing the results into proper model instances.
*/
export class QueryExecutor {
collection;
modelConstructor;
transactionClient;
constructor(collection, modelConstructor, transactionClient) {
this.collection = collection;
this.modelConstructor = modelConstructor;
this.transactionClient = transactionClient;
}
/**
* Get the session for transaction operations
*/
getSession() {
return this.transactionClient?.getSession();
}
/**
* Execute query and return first result
*/
async first(finalFilters, selectFields, sortOptions, loadRelations, embedRelations) {
try {
const { executeHooks } = await import('../base_model/hooks_executor.js');
const modelClass = this.modelConstructor;
// Execute beforeFind hook (for single record queries)
try {
if (!(await executeHooks(this, 'beforeFind', modelClass))) {
return null; // Operation aborted by hook
}
}
catch (error) {
throw new HookExecutionException('beforeFind', modelClass.name, error);
}
// Execute beforeFetch hook
try {
if (!(await executeHooks(this, 'beforeFetch', modelClass))) {
return null; // Operation aborted by hook
}
}
catch (error) {
throw new HookExecutionException('beforeFetch', modelClass.name, error);
}
const options = {};
if (selectFields) {
options.projection = selectFields;
}
if (sortOptions && Object.keys(sortOptions).length > 0) {
options.sort = sortOptions;
}
// Add session for transaction support
const session = this.getSession();
if (session) {
options.session = session;
}
let result;
try {
result = await this.collection.findOne(finalFilters, options);
}
catch (error) {
throw new DatabaseOperationException(`findOne on ${modelClass.name}`, error);
}
if (!result)
return null;
const deserializedResult = this.deserializeDocument(result);
// Create a proper model instance
const model = new this.modelConstructor();
model.hydrateFromDocument(deserializedResult);
Object.assign(model, deserializedResult);
Object.setPrototypeOf(model, this.modelConstructor.prototype);
// Associate model with transaction if present
if (this.transactionClient) {
model.useTransaction(this.transactionClient);
}
// Load relations if specified (eager loading like Lucid's preload)
if (loadRelations && loadRelations.size > 0) {
try {
await this.loadReferencedDocuments([model], loadRelations);
}
catch (error) {
throw new RelationshipException('referenced relationships', modelClass.name, error);
}
}
// Process embedded relations if specified
if (embedRelations && embedRelations.size > 0) {
try {
await this.processEmbeddedDocuments([model], embedRelations);
}
catch (error) {
throw new RelationshipException('embedded relationships', modelClass.name, error);
}
}
// Execute afterFind hook
try {
await executeHooks(model, 'afterFind', modelClass);
}
catch (error) {
throw new HookExecutionException('afterFind', modelClass.name, error);
}
// Execute afterFetch hook with array containing single model
try {
await executeHooks([model], 'afterFetch', modelClass);
}
catch (error) {
throw new HookExecutionException('afterFetch', modelClass.name, error);
}
return model;
}
catch (error) {
// Re-throw our custom exceptions as-is, wrap others
if (error instanceof DatabaseOperationException ||
error instanceof HookExecutionException ||
error instanceof RelationshipException) {
throw error;
}
throw new DatabaseOperationException(`first on ${this.modelConstructor.name}`, error);
}
}
/**
* Execute query and return first result or throw exception
*/
async firstOrFail(finalFilters, selectFields, sortOptions, loadRelations, embedRelations) {
const result = await this.first(finalFilters, selectFields, sortOptions, loadRelations, embedRelations);
if (!result) {
throw new ModelNotFoundException(this.modelConstructor.name);
}
return result;
}
/**
* Execute query and return all results
*/
async fetch(finalFilters, selectFields, sortOptions, limitValue, skipValue, distinctField, groupByFields, havingConditions, loadRelations, embedRelations) {
const { executeHooks } = await import('../base_model/hooks_executor.js');
const modelClass = this.modelConstructor;
// Execute beforeFetch hook
if (!(await executeHooks(this, 'beforeFetch', modelClass))) {
return []; // Operation aborted by hook
}
// Handle distinct queries
if (distinctField) {
const session = this.getSession();
const distinctOptions = session ? { session } : {};
const distinctValues = await this.collection.distinct(distinctField, finalFilters, distinctOptions);
return distinctValues.map((value) => ({
[distinctField]: value,
}));
}
// Handle group by queries (using aggregation)
if (groupByFields && groupByFields.length > 0) {
const aggregationResults = await this.executeAggregation(finalFilters, groupByFields, havingConditions, selectFields, sortOptions, skipValue, limitValue);
return this.serializeResults(aggregationResults);
}
const options = {};
if (selectFields) {
options.projection = selectFields;
}
if (sortOptions && Object.keys(sortOptions).length > 0) {
options.sort = sortOptions;
}
if (limitValue !== undefined) {
options.limit = limitValue;
}
if (skipValue !== undefined) {
options.skip = skipValue;
}
// Add session for transaction support
const session = this.getSession();
if (session) {
options.session = session;
}
const cursor = this.collection.find(finalFilters, options);
const results = await cursor.toArray();
const deserializedResults = results.map((doc) => this.deserializeDocument(doc));
// Create proper model instances
const modelInstances = deserializedResults.map((doc) => {
const model = new this.modelConstructor();
model.hydrateFromDocument(doc);
Object.assign(model, doc);
Object.setPrototypeOf(model, this.modelConstructor.prototype);
return model;
});
// Associate models with transaction if present
if (this.transactionClient) {
modelInstances.forEach((model) => {
model.useTransaction(this.transactionClient);
});
}
// Load relations if specified (eager loading like Lucid's preload)
if (loadRelations && loadRelations.size > 0) {
await this.loadReferencedDocuments(modelInstances, loadRelations);
}
// Process embedded relations if specified
if (embedRelations && embedRelations.size > 0) {
await this.processEmbeddedDocuments(modelInstances, embedRelations);
}
// Execute afterFetch hook
await executeHooks(modelInstances, 'afterFetch', modelClass);
return modelInstances;
}
/**
* Execute aggregation pipeline for group by queries
*/
async executeAggregation(finalFilters, groupByFields, havingConditions, selectFields, sortOptions, skipValue, limitValue) {
const pipeline = [];
// Match stage (equivalent to WHERE)
if (Object.keys(finalFilters).length > 0) {
pipeline.push({ $match: finalFilters });
}
// Group stage
const groupStage = {
_id: {},
};
// Add group by fields to _id
groupByFields.forEach((field) => {
groupStage._id[field] = `$${field}`;
});
// Add selected fields or count
if (selectFields) {
Object.keys(selectFields).forEach((field) => {
if (selectFields[field] === 1 && !groupByFields.includes(field)) {
groupStage[field] = { $first: `$${field}` };
}
});
}
else {
groupStage.count = { $sum: 1 };
}
pipeline.push({ $group: groupStage });
// Having stage (equivalent to HAVING)
if (havingConditions && Object.keys(havingConditions).length > 0) {
pipeline.push({ $match: havingConditions });
}
// Sort stage
if (sortOptions && Object.keys(sortOptions).length > 0) {
pipeline.push({ $sort: sortOptions });
}
// Skip stage
if (skipValue !== undefined) {
pipeline.push({ $skip: skipValue });
}
// Limit stage
if (limitValue !== undefined) {
pipeline.push({ $limit: limitValue });
}
const results = await this.collection.aggregate(pipeline).toArray();
return results.map((doc) => this.deserializeDocument(doc));
}
/**
* Execute query and return paginated results
*/
async paginate(page, perPage, finalFilters, selectFields, sortOptions, loadRelations) {
const skip = (page - 1) * perPage;
const total = await this.collection.countDocuments(finalFilters);
const options = {
skip,
limit: perPage,
};
if (selectFields) {
options.projection = selectFields;
}
if (sortOptions && Object.keys(sortOptions).length > 0) {
options.sort = sortOptions;
}
const cursor = this.collection.find(finalFilters, options);
const results = await cursor.toArray();
const deserializedResults = results.map((doc) => this.deserializeDocument(doc));
// Load relations if specified (eager loading like Lucid's preload)
if (loadRelations && loadRelations.size > 0) {
await this.loadReferencedDocuments(deserializedResults, loadRelations);
}
const data = this.serializeResults(deserializedResults);
const lastPage = Math.ceil(total / perPage);
const baseUrl = ''; // This would be configured based on the application
const meta = {
total,
perPage,
currentPage: page,
lastPage,
firstPage: 1,
firstPageUrl: `${baseUrl}?page=1`,
lastPageUrl: `${baseUrl}?page=${lastPage}`,
nextPageUrl: page < lastPage ? `${baseUrl}?page=${page + 1}` : null,
previousPageUrl: page > 1 ? `${baseUrl}?page=${page - 1}` : null,
};
return { data, meta };
}
/**
* Count documents matching the query
*/
async count(finalFilters) {
return this.collection.countDocuments(finalFilters);
}
/**
* Get array of IDs for matching documents
*/
async ids(finalFilters) {
const options = {
projection: { _id: 1 },
};
// Add session for transaction support
const session = this.getSession();
if (session) {
options.session = session;
}
const cursor = this.collection.find(finalFilters, options);
const results = await cursor.toArray();
return results.map((doc) => doc._id);
}
/**
* Update documents matching the query
*/
async update(finalFilters, updateData) {
// Serialize the update data
const serializedData = {};
for (const [key, value] of Object.entries(updateData)) {
serializedData[key] = this.serializeValue(value, key);
}
// Add updatedAt timestamp if not explicitly provided
if (!serializedData.updatedAt) {
serializedData.updatedAt = new Date();
}
// Add session for transaction support
const session = this.getSession();
const options = session ? { session } : {};
const result = await this.collection.updateMany(finalFilters, {
$set: serializedData,
}, options);
return result.modifiedCount || 0;
}
/**
* Delete documents matching the query
*/
async delete(finalFilters) {
// Add session for transaction support
const session = this.getSession();
const options = session ? { session } : {};
const result = await this.collection.deleteMany(finalFilters, options);
return result.deletedCount || 0;
}
/**
* Deserialize document from MongoDB
*/
deserializeDocument(doc) {
// This would apply any deserialize functions from column metadata
// For now, just return the document as-is
return doc;
}
/**
* Serialize value for MongoDB
*/
serializeValue(value, _key) {
// This would apply any serialize functions from column metadata
// For now, just return the value as-is
return value;
}
/**
* Serialize a single result for API response
*/
serializeResult(result) {
// Always create a proper model instance
const model = new this.modelConstructor();
model.hydrateFromDocument(result);
// Copy all properties from the result to the model
Object.assign(model, result);
// Ensure the model has the correct prototype
Object.setPrototypeOf(model, this.modelConstructor.prototype);
// Now call toJSON on the properly constructed model instance
return model.toJSON();
}
/**
* Serialize multiple results for API response
*/
serializeResults(results) {
return results.map((result) => this.serializeResult(result));
}
/**
* Load referenced documents for the given results (eager loading like Lucid's preload)
* This implements the same functionality as AdonisJS Lucid's preload method
*
* FULL IMPLEMENTATION: Prevents N+1 query problems with bulk loading
*/
async loadReferencedDocuments(results, loadRelations) {
if (results.length === 0) {
return;
}
// For each relationship to load
for (const [relationName, callback] of loadRelations) {
// Get the model metadata to find relationship information
const metadata = this.modelConstructor.getMetadata();
const relationColumn = metadata.columns.get(relationName);
if (!relationColumn) {
continue; // Skip if column doesn't exist
}
// Handle embedded documents
if (relationColumn.isEmbedded) {
await this.loadEmbeddedDocuments(results, relationName, relationColumn, callback);
continue;
}
// Handle referenced documents
if (!relationColumn.isReference) {
continue; // Skip if not a relationship
}
// If the model name is still "deferred", we need to trigger lazy resolution
// by accessing the relationship property on a model instance
if (relationColumn.model === 'deferred') {
const tempInstance = new this.modelConstructor();
// Access the relationship property to trigger lazy resolution
// This triggers lazy resolution and updates relationColumn.model
void tempInstance[relationName];
}
// Determine relationship type and load accordingly
if (relationColumn.isBelongsTo) {
await this.loadBelongsToRelationship(results, relationName, relationColumn, callback);
}
else if (relationColumn.isArray) {
await this.loadHasManyRelationship(results, relationName, relationColumn, callback);
}
else {
await this.loadHasOneRelationship(results, relationName, relationColumn, callback);
}
}
}
/**
* Load BelongsTo relationships in bulk (prevents N+1 queries)
*/
async loadBelongsToRelationship(results, relationName, relationColumn, callback) {
// 1. Collect all foreign keys from the results
const foreignKeys = results
.map((result) => result[relationColumn.localKey])
.filter((key) => key !== null && key !== undefined);
if (foreignKeys.length === 0)
return;
// 2. Get the related model class
const RelatedModel = this.getRelatedModelClass(relationColumn.model);
// 3. Query all related documents in a single query
// Apply naming strategy to convert field name to database column name
const dbFieldName = RelatedModel.namingStrategy.columnName(RelatedModel, relationColumn.foreignKey);
let relatedQuery = RelatedModel.query().whereIn(dbFieldName, foreignKeys);
if (this.transactionClient) {
relatedQuery = relatedQuery.useTransaction(this.transactionClient);
}
// 4. Apply callback constraints if provided
if (callback && typeof callback === 'function') {
callback(relatedQuery);
}
const relatedDocuments = await relatedQuery.all();
// 5. Create a map for quick lookup
const relatedMap = new Map();
relatedDocuments.forEach((doc) => {
const key = doc[dbFieldName];
relatedMap.set(key?.toString(), doc);
});
// 6. Map related documents back to their parent models
for (const result of results) {
// Convert result to a proper model instance if it isn't already
if (!(result instanceof BaseModel)) {
const model = new this.modelConstructor();
model.hydrateFromDocument(result);
Object.setPrototypeOf(result, model.constructor.prototype);
Object.assign(result, model);
}
const foreignKey = result[relationColumn.localKey];
if (foreignKey) {
const relatedDoc = relatedMap.get(foreignKey.toString());
if (relatedDoc) {
// Access the relationship proxy to initialize it, then set the data
const relationshipProxy = result[relationName];
if (relationshipProxy) {
relationshipProxy.related = relatedDoc;
// Note: isLoaded is read-only, it's managed internally by the proxy
}
}
}
}
}
/**
* Load HasOne relationships in bulk (prevents N+1 queries)
*/
async loadHasOneRelationship(results, relationName, relationColumn, callback) {
// 1. Collect all local keys from the results
const localKeys = results
.map((result) => result[relationColumn.localKey])
.filter((key) => key !== null && key !== undefined);
if (localKeys.length === 0) {
return;
}
// 2. Get the related model class
const RelatedModel = this.getRelatedModelClass(relationColumn.model);
// 3. Query all related documents in a single query
// Apply naming strategy to convert field name to database column name
const dbFieldName = RelatedModel.namingStrategy.columnName(RelatedModel, relationColumn.foreignKey);
let relatedQuery = RelatedModel.query().whereIn(dbFieldName, localKeys);
if (this.transactionClient) {
relatedQuery = relatedQuery.useTransaction(this.transactionClient);
}
// 4. Apply callback constraints if provided
if (callback && typeof callback === 'function') {
callback(relatedQuery);
}
const relatedDocuments = await relatedQuery.all();
// 5. Create a map for quick lookup
const relatedMap = new Map();
relatedDocuments.forEach((doc) => {
const key = doc[dbFieldName];
relatedMap.set(key?.toString(), doc);
});
// 6. Map related documents back to their parent models
for (const result of results) {
// Convert result to a proper model instance if it isn't already
if (!(result instanceof BaseModel)) {
const model = new this.modelConstructor();
model.hydrateFromDocument(result);
Object.setPrototypeOf(result, model.constructor.prototype);
Object.assign(result, model);
}
const localKey = result[relationColumn.localKey];
if (localKey) {
const relatedDoc = relatedMap.get(localKey.toString());
if (relatedDoc) {
// Access the relationship proxy to initialize it, then set the data
const relationshipProxy = result[relationName];
if (relationshipProxy) {
relationshipProxy.related = relatedDoc;
}
}
}
}
}
/**
* Load HasMany relationships in bulk (prevents N+1 queries)
*/
async loadHasManyRelationship(results, relationName, relationColumn, callback) {
// 1. Collect all local keys from the results
const localKeys = results
.map((result) => result[relationColumn.localKey])
.filter((key) => key !== null && key !== undefined);
if (localKeys.length === 0)
return;
// 2. Get the related model class
const RelatedModel = this.getRelatedModelClass(relationColumn.model);
// 3. Query all related documents in a single query
// Apply naming strategy to convert field name to database column name
const dbFieldName = RelatedModel.namingStrategy.columnName(RelatedModel, relationColumn.foreignKey);
let relatedQuery = RelatedModel.query().whereIn(dbFieldName, localKeys);
if (this.transactionClient) {
relatedQuery = relatedQuery.useTransaction(this.transactionClient);
}
// 4. Apply callback constraints if provided
if (callback && typeof callback === 'function') {
callback(relatedQuery);
}
const relatedDocuments = await relatedQuery.all();
// 5. Group related documents by foreign key
const relatedGroups = new Map();
relatedDocuments.forEach((doc) => {
const key = doc[dbFieldName]?.toString();
if (key) {
if (!relatedGroups.has(key)) {
relatedGroups.set(key, []);
}
relatedGroups.get(key).push(doc);
}
});
// 6. Map related documents back to their parent models
for (const result of results) {
// Convert result to a proper model instance if it isn't already
if (!(result instanceof BaseModel)) {
const model = new this.modelConstructor();
model.hydrateFromDocument(result);
Object.setPrototypeOf(result, model.constructor.prototype);
Object.assign(result, model);
}
const localKey = result[relationColumn.localKey];
if (localKey) {
const relatedDocs = relatedGroups.get(localKey.toString()) || [];
// Access the relationship proxy to initialize it, then set the data
const relationshipProxy = result[relationName];
if (relationshipProxy) {
relationshipProxy.related = relatedDocs;
// Note: isLoaded is read-only, it's managed internally by the proxy
// Update the array proxy
relationshipProxy.length = 0;
relationshipProxy.push(...relatedDocs);
}
}
}
}
/**
* Load embedded documents with query capabilities
* For embedded documents, we apply the callback constraints to filter the embedded data
*/
async loadEmbeddedDocuments(results, relationName, _relationColumn, callback) {
// For embedded documents, we need to apply the callback constraints
// to filter the embedded data if it's an array (many type)
for (const result of results) {
// Convert result to a proper model instance if it isn't already
if (!(result instanceof BaseModel)) {
const model = new this.modelConstructor();
model.hydrateFromDocument(result);
Object.setPrototypeOf(result, model.constructor.prototype);
Object.assign(result, model);
}
// Access the embedded property to ensure it's initialized
const embeddedData = result[relationName];
// If it's an embedded many type and has query capabilities, apply callback
if (embeddedData && typeof embeddedData.query === 'function' && callback) {
// Create a query builder for the embedded data
const embeddedQuery = embeddedData.query();
// Apply the callback constraints
callback(embeddedQuery);
// Execute the query to filter the embedded data
const filteredData = embeddedQuery.get();
// Update the embedded data with filtered results
// Note: This doesn't modify the original data, just provides filtered access
// In a real implementation, you might want to cache this or handle it differently
embeddedData._filteredResults = filteredData;
}
}
}
/**
* Process embedded documents with query capabilities
* This method applies the embed() callback constraints to filter embedded documents
*/
async processEmbeddedDocuments(results, embedRelations) {
if (results.length === 0) {
return;
}
// For each embedded relationship to process
for (const [relationName, callback] of embedRelations) {
// Get the model metadata to find embedded relationship information
const metadata = this.modelConstructor.getMetadata();
const relationColumn = metadata.columns.get(relationName);
if (!relationColumn || !relationColumn.isEmbedded) {
continue; // Skip if not an embedded relationship
}
// Process each result
for (const result of results) {
// Convert result to a proper model instance if it isn't already
if (!(result instanceof BaseModel)) {
const model = new this.modelConstructor();
model.hydrateFromDocument(result);
Object.setPrototypeOf(result, model.constructor.prototype);
Object.assign(result, model);
}
// Access the embedded property to ensure it's initialized
const embeddedData = result[relationName];
// Apply callback constraints for embedded many types (arrays)
if (embeddedData && typeof embeddedData.query === 'function' && callback) {
// Create a query builder for the embedded data
const embeddedQuery = embeddedData.query();
// Apply the callback constraints
callback(embeddedQuery);
// Execute the query to get filtered results
const filteredData = embeddedQuery.get();
// Replace the embedded data with filtered results
// This modifies the actual embedded array to show only filtered items
if (Array.isArray(embeddedData)) {
// Clear the existing array and add filtered items
embeddedData.length = 0;
embeddedData.push(...filteredData);
}
}
// For single embedded documents, we could apply validation or transformation
// but typically single embedded documents don't need query filtering
else if (embeddedData && relationColumn.embeddedType === 'single') {
// For single embedded documents, we could create a temporary query builder
// and apply the callback for validation purposes, but this is less common
console.log(`⚠️ Embed callback applied to single embedded document '${relationName}' - this is unusual`);
}
}
}
}
/**
* Get the related model class by name
* Uses the global model registry for relationship loading
*/
getRelatedModelClass(modelName) {
const ModelClass = BaseModel.getModelClass(modelName);
if (!ModelClass) {
throw new Error(`Model "${modelName}" not found in registry. ` +
'Make sure the model is imported and instantiated at least once, ' +
'or manually register it using ModelClass.register().');
}
return ModelClass;
}
}