import type { SchemaDefinition } from "../schemas/defineSchema"; import type { MonkkoClient } from "../connections/createConnection"; import type { Filter, WithId, Collection, Document } from "mongodb"; import { ObjectId } from "mongodb"; import type { PopulateOptions, QueryBuilder, SingleQueryBuilder, Populate, Prettify, JSONSerialized, } from "./types"; import type { ObjectIdField } from "../schemas/fields/field-types/objectId"; // Internal populate info interface PopulateInfo { fields: string[]; options: PopulateOptions; } // Global registry for schemas to help with populate const schemaRegistry = new Map(); export function registerSchema(schema: SchemaDefinition) { schemaRegistry.set(schema.name, schema); } abstract class QueryBuilderBase implements PromiseLike { protected populates: PopulateInfo[] = []; constructor( protected collection: Collection, protected schema: SchemaDefinition, protected monkkoClient: MonkkoClient, protected filter: Filter, protected toJSONFunc: (doc: WithId) => Prettify>>, ) {} protected _populate(field: string, options: PopulateOptions = {}) { const fieldArray = [field]; const totalFields = this.populates.reduce((acc, p) => acc + p.fields.length, 0) + fieldArray.length; const defaultStrategy = totalFields >= 2 ? "aggregation" : "multiple"; this.populates.push({ fields: fieldArray, options: { strategy: defaultStrategy, ...options }, }); return this; } protected abstract executeQuery(): Promise; then( onfulfilled?: | ((value: TReturn) => TResult1 | PromiseLike) | null, onrejected?: // eslint-disable-next-line @typescript-eslint/no-explicit-any | ((reason: any) => TResult2 | PromiseLike) | null, ): Promise { return this.executeQuery().then(onfulfilled, onrejected); } } export class QueryBuilderImpl extends QueryBuilderBase[]> implements QueryBuilder { populate( field: K, options: PopulateOptions = {}, ): QueryBuilder>> { this._populate(field as string, options); return this as unknown as QueryBuilder>>; } async toJSON() { const docs = await this.executeQuery(); return docs.map(this.toJSONFunc); } protected async executeQuery(): Promise[]> { const docs = await this.collection.find(this.filter || {}).toArray(); if (!docs || (Array.isArray(docs) && docs.length === 0)) { return docs as WithId[]; } // Process populates const documentsToProcess = Array.isArray(docs) ? docs : [docs]; for (const populateInfo of this.populates) { // For now, we'll implement only the multiple queries strategy if ( populateInfo.options.strategy === "multiple" || populateInfo.options.strategy === undefined ) { await executePopulateWithMultipleQueries( documentsToProcess as WithId[], populateInfo, this.schema, this.monkkoClient, ); } // TODO: Add aggregation strategy implementation } return docs as WithId[]; } } export class SingleQueryBuilderImpl extends QueryBuilderBase | null> implements SingleQueryBuilder { populate( field: K, options: PopulateOptions = {}, ): SingleQueryBuilder>> { this._populate(field as string, options); return this as unknown as SingleQueryBuilder>>; } async toJSON() { const doc = await this.executeQuery(); if (!doc) return null; return this.toJSONFunc(doc); } protected async executeQuery(): Promise | null> { const doc = await this.collection.findOne(this.filter); if (!doc) { return null; } // Process populates const documentsToProcess = [doc]; for (const populateInfo of this.populates) { // For now, we'll implement only the multiple queries strategy if ( populateInfo.options.strategy === "multiple" || populateInfo.options.strategy === undefined ) { await executePopulateWithMultipleQueries( documentsToProcess as WithId[], populateInfo, this.schema, this.monkkoClient, ); } // TODO: Add aggregation strategy implementation } return doc as WithId | null; } } async function executePopulateWithMultipleQueries( documents: WithId[], populateInfo: PopulateInfo, schema: SchemaDefinition, monkkoClient: MonkkoClient ) { for (const fieldName of populateInfo.fields) { // Find the field definition in the schema const fieldDef = schema.fields[fieldName]; if (!fieldDef || fieldDef.type !== 'objectId') { console.warn(`Field ${fieldName} is not an ObjectId field or doesn't exist in schema`); continue; } // Get the ref from the field definition const ref = (fieldDef as ObjectIdField).ref; if (!ref) { console.warn(`Field ${fieldName} doesn't have a ref property`); continue; } // Get the referenced schema to find the correct collection const referencedSchema = schemaRegistry.get(ref); if (!referencedSchema) { console.warn(`Referenced schema ${ref} not found in registry. Make sure to register all schemas.`); continue; } // Collect all the IDs to populate const idsToPopulate = new Set(); documents.forEach(doc => { const fieldValue = (doc as Record)[fieldName]; if (fieldValue) { if (Array.isArray(fieldValue)) { fieldValue.forEach(id => { if (id) idsToPopulate.add(id.toString()); }); } else { idsToPopulate.add(fieldValue.toString()); } } }); if (idsToPopulate.size === 0) continue; // Convert string IDs back to ObjectIds for the query const objectIds: ObjectId[] = []; const stringIds: string[] = []; Array.from(idsToPopulate).forEach(id => { try { objectIds.push(new ObjectId(id)); } catch { stringIds.push(id); // In case it's not a valid ObjectId, keep as string } }); // Get the referenced collection const refCollection = monkkoClient.client .db(referencedSchema.db) .collection(referencedSchema.collection); // Create query filter that handles both ObjectId and string IDs const queryFilter: Record = {}; if (objectIds.length > 0 && stringIds.length > 0) { queryFilter._id = { $in: [...objectIds, ...stringIds] }; } else if (objectIds.length > 0) { queryFilter._id = { $in: objectIds }; } else if (stringIds.length > 0) { queryFilter._id = { $in: stringIds }; } // Fetch the referenced documents const referencedDocs = await refCollection.find(queryFilter).toArray(); // Create a map for quick lookup const referencedMap = new Map(); referencedDocs.forEach(doc => { referencedMap.set(doc._id.toString(), doc); }); // Replace the IDs with the actual documents documents.forEach(doc => { const docRecord = doc as Record; const fieldValue = docRecord[fieldName]; if (fieldValue) { if (Array.isArray(fieldValue)) { docRecord[fieldName] = fieldValue.map(id => { const refDoc = referencedMap.get(id.toString()); return refDoc || id; // Keep original ID if not found }); } else { const refDoc = referencedMap.get(fieldValue.toString()); docRecord[fieldName] = refDoc || fieldValue; // Keep original ID if not found } } }); } }