Skip to content
UQL

MongoDB

MongoDB is a first-class backend, not a translation layer bolted on: the same entity classes and the same JSON query run there, and UQL compiles them to find cursors or aggregation pipelines instead of SQL. What follows is only what differs from the SQL dialects.

Terminal window
npm install uql-orm mongodb
import { MongodbQuerierPool } from 'uql-orm/mongo';
export const pool = new MongodbQuerierPool(process.env.MONGO_URL!, {
maxPoolSize: 10,
});

The second argument is the driver’s MongoClientOptions verbatim (maxPoolSize is its cap on connections), and the third is UQL’s extra options. The pool’s lifecycle is the same as on SQL; pool.end() closes the client.

MongoDB stores the primary key as _id; UQL maps it to whatever you named your @Id field, both ways:

import { v7 as uuidv7 } from 'uuid';
import { Entity, Field, Id } from 'uql-orm';
@Entity()
class User {
@Id({ type: String, onInsert: uuidv7 }) id?: string;
@Field({ type: String }) email?: string | null;
}

An id is an ObjectId in the database and a string in your code: a 24-character hex string becomes an ObjectId going in and its hex string coming back. Anything else, a UUID or a number, is stored as given. Fields with references convert the same way.

$exclude: { id: true } produces the _id: 0 projection MongoDB requires.

The only key a server generates is an ObjectId, so a key left to the database has to be declared a string:

The declaration On MongoDB
@Id({ type: String }) the server mints an ObjectId
@Id({ type: String, onInsert: uuidv7 }) you mint it, portable everywhere
@Id({ type: Number }) refused at the first write

MongoDB cannot mint a number, and answering a number declaration with a string would be a lie. uuidv7 is time-ordered, so inserts stay local in the _id index instead of scattering across it.

Relations compile to $lookup stages, so a read is one aggregation pipeline and $populate behaves as on SQL.

A query that only reads scalar fields skips the pipeline and uses a plain find cursor, which is the faster path. Filtering on a relation forces aggregation, since a cursor cannot express the join, and so do filtering by a relation aggregate and ordering by one. count and aggregate filter the same way. A write’s filter hosts no $lookup, so an updateMany or deleteMany filtered by either reads the ids of the documents it names first.

Ordering by a related field is the one place MongoDB asks for more than the SQL dialects do: the relation has to be populated as well as sorted by, at every level of the path. A $lookup adds a field to the result rather than being invisible the way a join is, so UQL will not add one your query did not ask for.

Projection carries over to the aggregation path: $select and $exclude narrow the columns whether or not a relation is in play, and a relation’s own projection narrows it too. The $project stages are placed after the lookups have read the join keys, so nothing you populate is lost to a column you dropped.

A relation aggregate reads here as it does on SQL: the declaration is data, not SQL, so it compiles to a $lookup ending in a $count or a $group.

A computed field writing SQL is the one form MongoDB has nothing to run. Naming one in $select, $where or $sort is refused; swept in with the rest of the entity’s fields it is skipped.

querier.transaction(...) opens a driver session and a real multi-document transaction, which MongoDB only supports on a replica set or a sharded cluster. Against a standalone mongod the server rejects it. A single-node replica set is enough in development:

Terminal window
docker run -p 27017:27017 mongo --replSet rs0 --bind_ip_all
# then, once: mongosh --eval 'rs.initiate()'

Isolation levels are a SQL concept, so isolationLevel is ignored here.

findManyStream reads through the cursor findMany would use, a plain find or an aggregation pipeline, so a streamed row carries the relations a read would. See Streaming.

  • Full-text needs a text index, which declares its own fields, weights and language: $text accepts $fields for API consistency and ignores it, and $sort: { $text } ranks by textScore. A fulltext @Index creates it. See Full-text search.
  • Vector search uses Atlas $vectorSearch over the index a type: 'vectorSearch' @Index declares, which migrations create. $candidates maps to the stage’s numCandidates, but $near throws: Atlas scores by the index’s own similarity, not a distance, so a bound cannot be converted without guessing the metric. Project the score and filter on it. Ranking by a relation’s nearest row needs no Atlas: the distance is computed exactly in the pipeline, $distance included. See Semantic search.
  • Hybrid search is native: MongoDB fuses a text match and a vector match with $rankFusion, where Postgres needs a hand-written reciprocal-rank-fusion query.

Migrations work with the same commands, written against the database handle instead of SQL, with the obvious caveat that a document store has no columns to alter:

  • generate scaffolds a migration typed on MongoQuerier, and generate:entities writes the collections and indexes your entities need as driver calls (await querier.db.createCollection("users")): @Field({ index }) and @Index, a partial one’s where as its partialFilterExpression (see Partial Indexes).
  • History lives in a uql_migrations collection, one document per migration keyed by its name. The config’s tableName renames it.
  • A migration runs outside any transaction, since MongoDB does not create collections or indexes inside one. One that fails halfway keeps what it already did and is not recorded, so write its steps to be safe to run again.
  • The migration builder takes createTable (a collection, its callback declaring indexes only), dropTable, renameTable, createIndex and dropIndex; a column, a foreign key or raw throws.
import { defineBuilderMigration, type MongoQuerier } from 'uql-orm/migrate';
export default defineBuilderMigration<MongoQuerier>({
async up(m) {
await m.renameTable('users', 'members');
await m.createIndex('members', ['email'], { unique: true });
},
async down(m) {
await m.dropIndex('members', 'members__email_idx');
await m.renameTable('members', 'users');
},
});

Field-level changes are data migrations you write yourself:

import { defineMigration, type MongoQuerier } from 'uql-orm/migrate';
export default defineMigration<MongoQuerier>({
async up(querier) {
await querier.db
.collection('users')
.updateMany({ active: { $exists: false } }, { $set: { active: true } });
},
async down(querier) {
await querier.db
.collection('users')
.updateMany({}, { $unset: { active: '' } });
},
});

The migration builder is SQL-only.