@dataql/mongodb-adapter
Version:
MongoDB adapter for DataQL with zero API changes
899 lines (783 loc) • 22.5 kB
text/typescript
import { Data } from "@dataql/core";
// Import DataOptions from the correct location
type DataOptions = {
dbName?: string;
env?: "dev" | "prod";
devPrefix?: string;
appToken?: string;
customConnection?: any; // CustomRequestConnection
};
// MongoDB-like types and interfaces
export interface MongoClientOptions {
// MongoDB compatibility options (ignored but supported for zero-API-change migration)
useNewUrlParser?: boolean;
useUnifiedTopology?: boolean;
maxPoolSize?: number;
serverSelectionTimeoutMS?: number;
socketTimeoutMS?: number;
family?: number;
bufferMaxEntries?: number;
readPreference?: string;
ssl?: boolean;
sslValidate?: boolean;
sslCA?: string;
sslCert?: string;
sslKey?: string;
sslPass?: string;
sslCRL?: string;
authSource?: string;
authMechanism?: string;
// DataQL configuration - operations go through DataQL infrastructure (Client → Worker → Lambda → MongoDB)
dataql?: DataOptions;
}
export interface InsertOneOptions {
bypassDocumentValidation?: boolean;
forceServerObjectId?: boolean;
writeConcern?: WriteConcern;
comment?: any;
}
export interface InsertManyOptions {
bypassDocumentValidation?: boolean;
forceServerObjectId?: boolean;
ordered?: boolean;
writeConcern?: WriteConcern;
comment?: any;
}
export interface UpdateOptions {
arrayFilters?: any[];
bypassDocumentValidation?: boolean;
collation?: any;
hint?: any;
upsert?: boolean;
writeConcern?: WriteConcern;
comment?: any;
}
export interface ReplaceOptions {
bypassDocumentValidation?: boolean;
collation?: any;
hint?: any;
upsert?: boolean;
writeConcern?: WriteConcern;
comment?: any;
}
export interface DeleteOptions {
collation?: any;
hint?: any;
writeConcern?: WriteConcern;
comment?: any;
}
export interface FindOptions {
allowDiskUse?: boolean;
allowPartialResults?: boolean;
batchSize?: number;
collation?: any;
comment?: any;
cursorType?: string;
hint?: any;
limit?: number;
max?: any;
maxAwaitTimeMS?: number;
maxTimeMS?: number;
min?: any;
noCursorTimeout?: boolean;
oplogReplay?: boolean;
projection?: any;
readConcern?: any;
readPreference?: any;
returnKey?: boolean;
showRecordId?: boolean;
skip?: number;
sort?: any;
tailable?: boolean;
awaitData?: boolean;
}
export interface WriteConcern {
w?: number | string;
j?: boolean;
wtimeout?: number;
}
export interface InsertOneResult {
acknowledged: boolean;
insertedId: any;
}
export interface InsertManyResult {
acknowledged: boolean;
insertedCount: number;
insertedIds: { [key: number]: any };
}
export interface UpdateResult {
acknowledged: boolean;
matchedCount: number;
modifiedCount: number;
upsertedId?: any;
upsertedCount: number;
}
export interface DeleteResult {
acknowledged: boolean;
deletedCount: number;
}
export interface BulkWriteOptions {
ordered?: boolean;
bypassDocumentValidation?: boolean;
writeConcern?: WriteConcern;
comment?: any;
}
export interface BulkWriteResult {
acknowledged: boolean;
insertedCount: number;
matchedCount: number;
modifiedCount: number;
deletedCount: number;
upsertedCount: number;
insertedIds: { [key: number]: any };
upsertedIds: { [key: number]: any };
}
export type BulkWriteOperation =
| { insertOne: { document: any } }
| { updateOne: { filter: any; update: any; upsert?: boolean } }
| { updateMany: { filter: any; update: any; upsert?: boolean } }
| { deleteOne: { filter: any } }
| { deleteMany: { filter: any } }
| { replaceOne: { filter: any; replacement: any; upsert?: boolean } };
// ObjectId class for MongoDB compatibility
export class ObjectId {
private _id: string;
constructor(id?: string | ObjectId) {
if (id instanceof ObjectId) {
this._id = id.toString();
} else if (typeof id === "string") {
this._id = id;
} else {
// Generate a random ObjectId-like string
this._id = this._generateObjectId();
}
}
private _generateObjectId(): string {
const timestamp = Math.floor(Date.now() / 1000).toString(16);
const randomBytes = Math.random().toString(16).substr(2, 16);
return (timestamp + randomBytes).substr(0, 24);
}
toString(): string {
return this._id;
}
toHexString(): string {
return this._id;
}
equals(other: ObjectId | string): boolean {
if (other instanceof ObjectId) {
return this._id === other._id;
}
return this._id === other;
}
getTimestamp(): Date {
const timestamp = parseInt(this._id.substr(0, 8), 16);
return new Date(timestamp * 1000);
}
static isValid(id: any): boolean {
if (typeof id === "string") {
return /^[0-9a-fA-F]{24}$/.test(id);
}
return id instanceof ObjectId;
}
static createFromHexString(hexString: string): ObjectId {
return new ObjectId(hexString);
}
}
// Cursor class for query results
export class FindCursor<T = any> implements AsyncIterable<T> {
private _data: Data;
private _collectionName: string;
private _filter: any;
private _options: FindOptions;
private _results?: T[];
constructor(
data: Data,
collectionName: string,
filter: any = {},
options: FindOptions = {}
) {
this._data = data;
this._collectionName = collectionName;
this._filter = filter;
this._options = options;
}
private get _collection() {
return this._data.collection(
this._collectionName,
this._getDefaultSchema()
);
}
private _getDefaultSchema() {
return {
_id: { type: "ID", required: true },
createdAt: { type: "Date", default: "now" },
updatedAt: { type: "Date", default: "now" },
};
}
async toArray(): Promise<T[]> {
if (this._results) {
return this._results;
}
let results = await this._collection.find(this._filter);
// Apply sorting
if (this._options.sort) {
results = this._applySorting(results, this._options.sort);
}
// Apply skip
if (this._options.skip) {
results = results.slice(this._options.skip);
}
// Apply limit
if (this._options.limit) {
results = results.slice(0, this._options.limit);
}
// Apply projection
if (this._options.projection) {
results = this._applyProjection(results, this._options.projection);
}
this._results = results;
return results;
}
private _applySorting(results: any[], sort: any): any[] {
return results.sort((a, b) => {
for (const [field, direction] of Object.entries(sort)) {
const aVal = a[field];
const bVal = b[field];
const sortDir = direction === -1 ? -1 : 1;
if (aVal < bVal) return -1 * sortDir;
if (aVal > bVal) return 1 * sortDir;
}
return 0;
});
}
private _applyProjection(results: any[], projection: any): any[] {
const isInclusion = Object.values(projection).some((val) => val === 1);
return results.map((doc) => {
if (isInclusion) {
const projected: any = {};
for (const [field, include] of Object.entries(projection)) {
if (include === 1) {
projected[field] = doc[field];
}
}
return projected;
} else {
const projected = { ...doc };
for (const [field, exclude] of Object.entries(projection)) {
if (exclude === 0) {
delete projected[field];
}
}
return projected;
}
});
}
async next(): Promise<T | null> {
const results = await this.toArray();
return results.length > 0 ? results.shift()! : null;
}
async hasNext(): Promise<boolean> {
const results = await this.toArray();
return results.length > 0;
}
async forEach(fn: (doc: T) => void): Promise<void> {
const results = await this.toArray();
results.forEach(fn);
}
map<U>(fn: (doc: T) => U): FindCursor<U> {
// Return a new cursor with mapped results
const newCursor = new FindCursor<U>(
this._data,
this._collectionName,
this._filter,
this._options
);
return newCursor;
}
filter(fn: (doc: T) => boolean): FindCursor<T> {
// Return a new cursor with filtered results
return new FindCursor<T>(
this._data,
this._collectionName,
this._filter,
this._options
);
}
limit(count: number): FindCursor<T> {
const newCursor = new FindCursor<T>(
this._data,
this._collectionName,
this._filter,
{ ...this._options, limit: count }
);
return newCursor;
}
skip(count: number): FindCursor<T> {
const newCursor = new FindCursor<T>(
this._data,
this._collectionName,
this._filter,
{ ...this._options, skip: count }
);
return newCursor;
}
sort(sort: any): FindCursor<T> {
const newCursor = new FindCursor<T>(
this._data,
this._collectionName,
this._filter,
{ ...this._options, sort }
);
return newCursor;
}
project(projection: any): FindCursor<T> {
const newCursor = new FindCursor<T>(
this._data,
this._collectionName,
this._filter,
{ ...this._options, projection }
);
return newCursor;
}
async count(): Promise<number> {
const results = await this._collection.find(this._filter);
return results.length;
}
async *[Symbol.asyncIterator](): AsyncIterator<T> {
const results = await this.toArray();
for (const result of results) {
yield result;
}
}
}
// Collection class
export class Collection<T = any> {
constructor(
private _data: Data,
private _db: Db,
public collectionName: string
) {}
private get _collection() {
return this._data.collection(this.collectionName, this._getDefaultSchema());
}
private _getDefaultSchema() {
return {
_id: { type: "ID", required: true },
createdAt: { type: "Date", default: "now" },
updatedAt: { type: "Date", default: "now" },
};
}
// Insert operations
async insertOne(
doc: T,
options?: InsertOneOptions
): Promise<InsertOneResult> {
const docWithId = {
_id: new ObjectId(),
...doc,
};
const result = await this._collection.create(docWithId);
return {
acknowledged: true,
insertedId: docWithId._id,
};
}
async insertMany(
docs: T[],
options?: InsertManyOptions
): Promise<InsertManyResult> {
const docsWithIds = docs.map((doc, index) => ({
_id: new ObjectId(),
...doc,
}));
const results = await this._collection.create(docsWithIds);
const insertedIds: { [key: number]: any } = {};
docsWithIds.forEach((doc, index) => {
insertedIds[index] = doc._id;
});
return {
acknowledged: true,
insertedCount: docsWithIds.length,
insertedIds,
};
}
// Find operations
find(filter: any = {}, options?: FindOptions): FindCursor<T> {
return new FindCursor<T>(this._data, this.collectionName, filter, options);
}
async findOne(filter: any = {}, options?: FindOptions): Promise<T | null> {
const cursor = this.find(filter, { ...options, limit: 1 });
const results = await cursor.toArray();
return results.length > 0 ? results[0] : null;
}
async findOneAndUpdate(
filter: any,
update: any,
options?: UpdateOptions & { returnDocument?: "before" | "after" }
): Promise<{ value: T | null }> {
const existing = await this.findOne(filter);
if (!existing && !options?.upsert) {
return { value: null };
}
const updateResult = await this._collection.update(
filter,
update,
options?.upsert
);
if (options?.returnDocument === "before") {
return { value: existing };
} else {
const updated = await this.findOne(filter);
return { value: updated };
}
}
async findOneAndDelete(filter: any): Promise<{ value: T | null }> {
const existing = await this.findOne(filter);
if (existing) {
await this._collection.delete(filter);
}
return { value: existing };
}
async findOneAndReplace(
filter: any,
replacement: any,
options?: ReplaceOptions & { returnDocument?: "before" | "after" }
): Promise<{ value: T | null }> {
const existing = await this.findOne(filter);
if (!existing && !options?.upsert) {
return { value: null };
}
await this._collection.update(filter, replacement, options?.upsert);
if (options?.returnDocument === "before") {
return { value: existing };
} else {
const updated = await this.findOne(filter);
return { value: updated };
}
}
// Update operations
async updateOne(
filter: any,
update: any,
options?: UpdateOptions
): Promise<UpdateResult> {
const result = await this._collection.update(
filter,
update,
options?.upsert
);
return {
acknowledged: true,
matchedCount: result ? 1 : 0,
modifiedCount: result ? 1 : 0,
upsertedId: options?.upsert && !result ? new ObjectId() : undefined,
upsertedCount: options?.upsert && !result ? 1 : 0,
};
}
async updateMany(
filter: any,
update: any,
options?: UpdateOptions
): Promise<UpdateResult> {
// DataQL doesn't have updateMany, so we'll find and update individually
const existing = await this._collection.find(filter);
let modifiedCount = 0;
for (const doc of existing) {
const result = await this._collection.update({ _id: doc._id }, update);
if (result) modifiedCount++;
}
return {
acknowledged: true,
matchedCount: existing.length,
modifiedCount,
upsertedCount: 0,
};
}
async replaceOne(
filter: any,
replacement: any,
options?: ReplaceOptions
): Promise<UpdateResult> {
const result = await this._collection.update(
filter,
replacement,
options?.upsert
);
return {
acknowledged: true,
matchedCount: result ? 1 : 0,
modifiedCount: result ? 1 : 0,
upsertedId: options?.upsert && !result ? new ObjectId() : undefined,
upsertedCount: options?.upsert && !result ? 1 : 0,
};
}
// Delete operations
async deleteOne(filter: any, options?: DeleteOptions): Promise<DeleteResult> {
const existing = await this.findOne(filter);
if (existing) {
await this._collection.delete(filter);
return {
acknowledged: true,
deletedCount: 1,
};
}
return {
acknowledged: true,
deletedCount: 0,
};
}
async deleteMany(
filter: any,
options?: DeleteOptions
): Promise<DeleteResult> {
const existing = await this._collection.find(filter);
let deletedCount = 0;
for (const doc of existing) {
await this._collection.delete({ _id: doc._id });
deletedCount++;
}
return {
acknowledged: true,
deletedCount,
};
}
// Bulk operations
async bulkWrite(
operations: BulkWriteOperation[],
options?: BulkWriteOptions
): Promise<BulkWriteResult> {
let insertedCount = 0;
let matchedCount = 0;
let modifiedCount = 0;
let deletedCount = 0;
let upsertedCount = 0;
const insertedIds: { [key: number]: any } = {};
const upsertedIds: { [key: number]: any } = {};
for (let i = 0; i < operations.length; i++) {
const operation = operations[i];
try {
if ("insertOne" in operation) {
const result = await this.insertOne(operation.insertOne.document);
insertedCount++;
insertedIds[i] = result.insertedId;
} else if ("updateOne" in operation) {
const result = await this.updateOne(
operation.updateOne.filter,
operation.updateOne.update,
{ upsert: operation.updateOne.upsert }
);
matchedCount += result.matchedCount;
modifiedCount += result.modifiedCount;
if (result.upsertedId) {
upsertedCount++;
upsertedIds[i] = result.upsertedId;
}
} else if ("updateMany" in operation) {
const result = await this.updateMany(
operation.updateMany.filter,
operation.updateMany.update,
{ upsert: operation.updateMany.upsert }
);
matchedCount += result.matchedCount;
modifiedCount += result.modifiedCount;
upsertedCount += result.upsertedCount;
} else if ("deleteOne" in operation) {
const result = await this.deleteOne(operation.deleteOne.filter);
deletedCount += result.deletedCount;
} else if ("deleteMany" in operation) {
const result = await this.deleteMany(operation.deleteMany.filter);
deletedCount += result.deletedCount;
} else if ("replaceOne" in operation) {
const result = await this.replaceOne(
operation.replaceOne.filter,
operation.replaceOne.replacement,
{ upsert: operation.replaceOne.upsert }
);
matchedCount += result.matchedCount;
modifiedCount += result.modifiedCount;
if (result.upsertedId) {
upsertedCount++;
upsertedIds[i] = result.upsertedId;
}
}
} catch (error) {
if (!options?.ordered) {
continue; // Continue with next operation if not ordered
}
throw error; // Stop on first error if ordered
}
}
return {
acknowledged: true,
insertedCount,
matchedCount,
modifiedCount,
deletedCount,
upsertedCount,
insertedIds,
upsertedIds,
};
}
// Count operations
async countDocuments(filter: any = {}): Promise<number> {
const results = await this._collection.find(filter);
return results.length;
}
async estimatedDocumentCount(): Promise<number> {
return this.countDocuments();
}
// Aggregation
aggregate(pipeline: any[]): any {
// Basic aggregation support - would need more sophisticated implementation
return {
toArray: async () => {
// For now, just return find results
const results = await this._collection.find({});
return results;
},
};
}
// Index operations (no-op for DataQL)
async createIndex(fieldOrSpec: any, options?: any): Promise<string> {
return "index_created";
}
async createIndexes(indexSpecs: any[]): Promise<string[]> {
return indexSpecs.map((_, i) => `index_${i}_created`);
}
async dropIndex(indexName: string): Promise<any> {
return { ok: 1 };
}
async dropIndexes(): Promise<any> {
return { ok: 1 };
}
async listIndexes(): Promise<any[]> {
return [];
}
// Utility methods
async distinct(field: string, filter: any = {}): Promise<any[]> {
const results = await this._collection.find(filter);
const values = results.map((doc) => doc[field]);
return [...new Set(values)];
}
// Drop collection
async drop(): Promise<boolean> {
// DataQL doesn't have explicit drop - return true for compatibility
return true;
}
}
// Database class
export class Db {
constructor(
private _data: Data,
public databaseName: string
) {}
collection<T = any>(name: string): Collection<T> {
return new Collection<T>(this._data, this, name);
}
async listCollections(): Promise<any[]> {
// DataQL doesn't track collections separately
return [];
}
async dropCollection(name: string): Promise<boolean> {
// DataQL doesn't have explicit drop - return true for compatibility
return true;
}
async dropDatabase(): Promise<any> {
// DataQL doesn't have explicit drop - return ok for compatibility
return { ok: 1 };
}
async stats(): Promise<any> {
return {
db: this.databaseName,
collections: 0,
views: 0,
objects: 0,
avgObjSize: 0,
dataSize: 0,
storageSize: 0,
totalSize: 0,
indexes: 0,
indexSize: 0,
scaleFactor: 1,
};
}
}
// MongoClient class
export class MongoClient {
private _data: Data;
private _connected = false;
private _databaseName?: string;
constructor(
private _url: string,
private _options?: MongoClientOptions
) {
// Extract database name from MongoDB URL for DataQL database isolation
this._databaseName = this._extractDatabaseName(_url);
// Configure DataQL to work through its infrastructure (Client → Worker → Lambda → MongoDB)
const dataqlOptions = {
// Pass database name for DataQL's per-client database isolation
dbName: this._databaseName || "mongodb_app",
// MongoDB URL will be handled by DataQL's infrastructure routing
appToken: _options?.dataql?.appToken || "mongodb_adapter",
env: _options?.dataql?.env || "prod",
devPrefix: _options?.dataql?.devPrefix || "mongodb_",
customConnection: _options?.dataql?.customConnection,
};
this._data = new Data(dataqlOptions);
}
private _extractDatabaseName(url: string): string | undefined {
try {
// Extract database name from MongoDB connection string
// mongodb://host:port/database or mongodb+srv://host/database
const match = url.match(/\/([^/?]+)(?:\?|$)/);
return match ? match[1] : undefined;
} catch {
return undefined;
}
}
async connect(): Promise<this> {
// Connection is handled by DataQL infrastructure
this._connected = true;
return this;
}
async close(): Promise<void> {
// Cleanup handled by DataQL
this._connected = false;
}
db(name?: string): Db {
if (!this._connected) {
throw new Error(
"MongoClient must be connected before running operations"
);
}
// Database operations go through DataQL infrastructure
const dbName = name || this._databaseName || "default";
return new Db(this._data, dbName);
}
isConnected(): boolean {
return this._connected;
}
// Static connect method
static async connect(
url: string,
options?: MongoClientOptions
): Promise<MongoClient> {
const client = new MongoClient(url, options);
await client.connect();
return client;
}
}
// Default export
export default MongoClient;
// Additional MongoDB utilities
export const BSON = {
ObjectId,
ObjectID: ObjectId, // Legacy alias
};
// Connection helper
export async function connect(
url: string,
options?: MongoClientOptions
): Promise<MongoClient> {
return MongoClient.connect(url, options);
}