Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion packages/lib/src/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1401,9 +1401,29 @@ export class SqlCompilerImpl implements SqlCompiler {
}
// Handle single values with value property
else if ('value' in value) {
return value.value;
return this.resolveObjectIdSentinel(value.value);
}
}
if (typeof value === 'string') {
return this.resolveObjectIdSentinel(value);
}
return value;
}

/**
* If the string is a sentinel injected by preprocessObjectIdCasts(), return a
* typed marker that the executor will convert to a real ObjectId instance.
* Otherwise return the string unchanged.
*/
private resolveObjectIdSentinel(value: string): any {
if (
typeof value === 'string' &&
value.startsWith('__QL_OBJECTID_') &&
value.endsWith('__')
) {
const hex = value.slice('__QL_OBJECTID_'.length, -2);
return { __qlObjectId: hex };
}
return value;
}

Expand Down
190 changes: 144 additions & 46 deletions packages/lib/src/executor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
CursorResult,
CursorOptions,
} from '../interfaces';
import { Document, MongoClient, ObjectId } from 'mongodb';
import { Db, Document, MongoClient, ObjectId } from 'mongodb';

/**
* MongoDB command executor implementation for Node.js
Expand Down Expand Up @@ -75,9 +75,14 @@ export class MongoExecutor implements CommandExecutor {
}

// Create the cursor with all options at once
const findFilter = await this.resolveFilterObjectIds(
database,
command.collection,
command.filter || {}
);
const findCursor = database
.collection(command.collection)
.find(this.convertObjectIds(command.filter || {}), findOptions);
.find(findFilter, findOptions);

// Always return array for the regular execute
result = await findCursor.toArray();
Expand All @@ -89,20 +94,29 @@ export class MongoExecutor implements CommandExecutor {
.insertMany(command.documents.map((doc) => this.convertObjectIds(doc)));
break;

case 'UPDATE':
case 'UPDATE': {
const updateFilter = await this.resolveFilterObjectIds(
database,
command.collection,
command.filter || {}
);
result = await database
.collection(command.collection)
.updateMany(
this.convertObjectIds(command.filter || {}),
this.convertObjectIds(command.update)
);
.updateMany(updateFilter, this.convertObjectIds(command.update));
break;
}

case 'DELETE':
case 'DELETE': {
const deleteFilter = await this.resolveFilterObjectIds(
database,
command.collection,
command.filter || {}
);
result = await database
.collection(command.collection)
.deleteMany(this.convertObjectIds(command.filter || {}));
.deleteMany(deleteFilter);
break;
}

case 'AGGREGATE':
// Handle aggregation commands
Expand Down Expand Up @@ -176,9 +190,14 @@ export class MongoExecutor implements CommandExecutor {
}

// Create the cursor with all options at once
const cursorFilter = await this.resolveFilterObjectIds(
database,
command.collection,
command.filter || {}
);
const findCursor = database
.collection(command.collection)
.find(this.convertObjectIds(command.filter || {}), findOptions);
.find(cursorFilter, findOptions);

// Return the cursor directly
result = findCursor;
Expand Down Expand Up @@ -214,9 +233,107 @@ export class MongoExecutor implements CommandExecutor {
}

/**
* Convert string ObjectIds to MongoDB ObjectId instances
* @param obj Object to convert
* @returns Object with converted ObjectIds
* Sample one document from the collection to discover which fields contain ObjectIds,
* then apply conversions to the filter accordingly.
*/
private async resolveFilterObjectIds(
db: Db,
collectionName: string,
filter: Record<string, any>
): Promise<Record<string, any>> {
const objectIdFields = new Set<string>();
const sample = await db.collection(collectionName).findOne({});
if (sample) {
for (const [key, value] of Object.entries(sample)) {
if (value instanceof ObjectId) {
objectIdFields.add(key);
}
}
} else {
// Empty collection: assume _id is ObjectId (MongoDB default)
objectIdFields.add('_id');
}
// Schema-based conversion first, then resolve any explicit cast sentinels
const schemaConverted = this.applyFilterConversions(filter, objectIdFields);
return this.resolveSentinels(schemaConverted);
}

/**
* Recursively resolve { __qlObjectId: hex } sentinels emitted by the compiler
* for explicit CAST('...' AS OBJECTID) / '...'::OBJECTID expressions.
*/
private resolveSentinels(value: any): any {
if (!value || typeof value !== 'object') return value;
if ('__qlObjectId' in value) {
try {
return new ObjectId(value.__qlObjectId);
} catch {
return value.__qlObjectId;
}
}
if (Array.isArray(value)) return value.map((v) => this.resolveSentinels(v));
// Skip non-plain objects (ObjectId, Date, RegExp, Buffer, etc.) — they are already
// properly typed BSON values and must not be destructured into plain objects.
const proto = Object.getPrototypeOf(value);
if (proto !== Object.prototype && proto !== null) return value;
const result: Record<string, any> = {};
for (const [k, v] of Object.entries(value)) result[k] = this.resolveSentinels(v);
return result;
}

/**
* Recursively apply ObjectId conversions to a filter using a set of known ObjectId fields.
* Handles logical operators ($and, $or, $nor) and comparison operators ($eq, $in, etc.).
*/
private applyFilterConversions(filter: any, objectIdFields: Set<string>): any {
if (!filter || typeof filter !== 'object') return filter;

if (Array.isArray(filter)) {
return filter.map((item) => this.applyFilterConversions(item, objectIdFields));
}

const result: Record<string, any> = {};
for (const [key, value] of Object.entries(filter)) {
if (key.startsWith('$')) {
// Logical operator ($and, $or, $nor) — recurse without a field context
result[key] = this.applyFilterConversions(value, objectIdFields);
} else if (objectIdFields.has(key)) {
// Known ObjectId field — convert any hex strings in the value
result[key] = this.convertToObjectId(value);
} else {
result[key] = value;
}
}
return result;
}

/**
* Convert a filter value (or nested operator expression) to ObjectId where applicable.
*/
private convertToObjectId(value: any): any {
if (typeof value === 'string' && /^[0-9a-fA-F]{24}$/.test(value)) {
try {
return new ObjectId(value);
} catch {
return value;
}
}
if (Array.isArray(value)) {
return value.map((v) => this.convertToObjectId(v));
}
if (value && typeof value === 'object') {
// Operator expression like { $eq: '...', $in: [...], $ne: '...' }
const result: Record<string, any> = {};
for (const [op, v] of Object.entries(value)) {
result[op] = this.convertToObjectId(v);
}
return result;
}
return value;
}

/**
* Recursively convert _id fields in documents (used for INSERT payloads).
*/
private convertObjectIds(obj: any): any {
if (!obj) return obj;
Expand All @@ -226,47 +343,28 @@ export class MongoExecutor implements CommandExecutor {
}

if (typeof obj === 'object') {
// Resolve explicit cast sentinels from compiler
if ('__qlObjectId' in obj) {
try {
return new ObjectId(obj.__qlObjectId);
} catch {
return obj.__qlObjectId;
}
}
const result: Record<string, any> = {};

for (const [key, value] of Object.entries(obj)) {
// Special handling for _id field and fields ending with Id
if (
(key === '_id' || key.endsWith('Id') || key.endsWith('Ids')) &&
typeof value === 'string'
) {
if (key === '_id' && typeof value === 'string' && /^[0-9a-fA-F]{24}$/.test(value)) {
try {
// Check if it's a valid ObjectId string
if (/^[0-9a-fA-F]{24}$/.test(value)) {
result[key] = new ObjectId(value);
continue;
}
} catch (error) {
// If it's not a valid ObjectId, keep it as a string
console.warn(`Could not convert ${key} value to ObjectId: ${value}`);
result[key] = new ObjectId(value);
} catch {
result[key] = value;
}
} else if (Array.isArray(value) && (key.endsWith('Ids') || key === 'productIds')) {
// For arrays of IDs
result[key] = value.map((item: any) => {
if (typeof item === 'string' && /^[0-9a-fA-F]{24}$/.test(item)) {
try {
return new ObjectId(item);
} catch (error) {
return item;
}
}
return this.convertObjectIds(item);
});
continue;
} else if (typeof value === 'object' && value !== null) {
// Recursively convert nested objects
result[key] = this.convertObjectIds(value);
continue;
} else {
result[key] = value;
}

// Copy other values as is
result[key] = value;
}

return result;
}

Expand Down
29 changes: 27 additions & 2 deletions packages/lib/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,11 @@ export class SqlParserImpl implements SqlParser {
*/
parse(sql: string): SqlStatement {
try {
// Transform explicit ObjectId cast syntax before any other preprocessing
const preprocessedObjectIdSql = this.preprocessObjectIdCasts(sql);

// First, handle nested dot notation in field access
const preprocessedNestedSql = this.preprocessNestedFields(sql);
const preprocessedNestedSql = this.preprocessNestedFields(preprocessedObjectIdSql);

// Then transform array index notation to a form the parser can handle
const preprocessedSql = this.preprocessArrayIndexes(preprocessedNestedSql);
Expand Down Expand Up @@ -97,7 +100,7 @@ export class SqlParserImpl implements SqlParser {

if (errorMessage.includes('[')) {
// Make a more aggressive transformation of the SQL for bracket syntax
const fallbackSql = this.aggressivePreprocessing(sql);
const fallbackSql = this.aggressivePreprocessing(this.preprocessObjectIdCasts(sql));
log('Fallback SQL for array syntax:', fallbackSql);
try {
const ast = this.parser.astify(fallbackSql, { database: 'PostgreSQL' });
Expand All @@ -120,6 +123,28 @@ export class SqlParserImpl implements SqlParser {
}
}

/**
* Preprocess explicit ObjectId cast syntax into a sentinel string literal
* that survives SQL parsing and is recognised later in convertValue().
*
* Supported forms (case-insensitive):
* CAST('507f...' AS OBJECTID) → '__QL_OBJECTID_507f...__'
* '507f...'::OBJECTID → '__QL_OBJECTID_507f...__'
*/
private preprocessObjectIdCasts(sql: string): string {
// CAST('value' AS OBJECTID)
let result = sql.replace(
/CAST\s*\(\s*'([^']*)'\s+AS\s+OBJECTID\s*\)/gi,
(_match, value) => `'__QL_OBJECTID_${value}__'`
);
// 'value'::OBJECTID
result = result.replace(
/'([^']*)'\s*::\s*OBJECTID/gi,
(_match, value) => `'__QL_OBJECTID_${value}__'`
);
return result;
}

/**
* Preprocess nested field access in SQL before parsing
*
Expand Down
Loading
Loading