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.
Install and connect
Section titled “Install and connect”npm install uql-orm mongodbimport { 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 key has to be one MongoDB can produce
Section titled “The key has to be one MongoDB can produce”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 |
the server mints an Object |
@Id |
you mint it, portable everywhere |
@Id |
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
Section titled “Relations”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.
Computed fields
Section titled “Computed fields”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.
Transactions need a replica set
Section titled “Transactions need a replica set”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:
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.
Streaming
Section titled “Streaming”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.
Search
Section titled “Search”- Full-text needs a
textindex, which declares its own fields, weights and language:$textaccepts$fieldsfor API consistency and ignores it, and$sort: { $text }ranks bytextScore. Afulltext@Indexcreates it. See Full-text search. - Vector search uses Atlas
$vectorSearchover the index atype: 'vectorSearch'@Indexdeclares, which migrations create.$candidatesmaps to the stage’snumCandidates, but$nearthrows: Atlas scores by the index’s ownsimilarity, 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,$distanceincluded. 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
Section titled “Migrations”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:
generatescaffolds a migration typed onMongoQuerier, andgenerate:entitieswrites the collections and indexes your entities need as driver calls (await querier.db.createCollection("users")):@Field({ index })and@Index, a partial one’swhereas itspartialFilterExpression(see Partial Indexes).- History lives in a
uql_migrationscollection, one document per migration keyed by its name. The config’stableNamerenames 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,createIndexanddropIndex; a column, a foreign key orrawthrows.
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.