Skip to content
NewComposite primary keys3 min read

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, and UQL maps it to whatever you named your @Id field on the way in and out, so entities stay portable:

import { Entity, Field, Id } from 'uql-orm';
@Entity()
class User {
@Id({ type: String }) id?: string;
@Field({ type: String }) email?: string;
}

An id is an ObjectId in the database and a string in your code, converted in both directions. $exclude: { id: true } produces the _id: 0 projection MongoDB requires.

To-one relations compile to $lookup stages in an aggregation pipeline. To-many relations run as a second query and are filled in afterwards, the same strategy the SQL dialects use, so $populate behaves identically.

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 does ordering by one.

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.

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 uses a native cursor, and it loads no relations at all: requesting any relation key throws rather than silently returning partial documents. Aggregation-based relation loading cannot be done row by row. See Streaming.

  • Full-text needs a text index, which declares its own fields. $text therefore accepts $fields for API consistency and ignores it. See Full-text search.
  • Vector search uses Atlas $vectorSearch, so it needs a vector index defined in Atlas rather than in a migration. $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. 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 obvious caveat that a document store has no columns to alter. UQL creates collections and indexes, and does it outside any transaction, since MongoDB does not allow those operations inside one. Field-level changes are data migrations you write yourself.