> Every UQL docs page, as Markdown: https://uql-orm.dev/llms.txt
> The same docs over MCP: https://uql-orm.dev/mcp
> Before writing UQL code, read the skill: https://uql-orm.dev/.well-known/agent-skills/uql-orm/SKILL.md

# MongoDB

> Run UQL on MongoDB with the same entities and queries you use on SQL, and where the two differ.

Source: https://uql-orm.dev/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

```sh
npm install uql-orm mongodb
```

```ts
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](https://uql-orm.dev/logging.md). The [pool’s lifecycle](https://uql-orm.dev/pool.md) is the same as on SQL; `pool.end()` closes the client.

## Ids

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

```ts
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

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

Relations compile to `$lookup` stages, so a read is one aggregation pipeline and [`$populate`](https://uql-orm.dev/querying/relations.md) 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](https://uql-orm.dev/entities/computed-fields.md#relation-aggregates) and [ordering by one](https://uql-orm.dev/querying/relations.md#sorting-by-related-fields). `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

A [relation aggregate](https://uql-orm.dev/entities/computed-fields.md#relation-aggregates) 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

`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:

```sh
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

`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](https://uql-orm.dev/querying/streaming.md).

## Search

- **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](https://uql-orm.dev/querying/full-text.md).
- **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](https://uql-orm.dev/querying/semantic-search.md#ranking-by-a-related-row).
- **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

[Migrations](https://uql-orm.dev/migrations.md) 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](https://uql-orm.dev/entities/indexes.md#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](https://uql-orm.dev/migrations/builder.md) takes `createTable` (a collection, its callback declaring indexes only), `dropTable`, `renameTable`, `createIndex` and `dropIndex`; a column, a foreign key or `raw` throws.

```typescript
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:

```typescript
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](https://uql-orm.dev/migrations/builder.md) is SQL-only.
