# mmo.mongodb — overview for AI code generation
`mmo.mongodb` (PHP namespace `MMO\mongodb`) is a MongoDB-backed alternative to `mmo.core`'s
`JsonRepository`: it's a `Repository` (`MongoRepository extends \MMO\core\classes\aRepository`, the
same base class `JsonRepository` extends) that persists the framework's normal `Entity`/`Collection`/`Query`
data layer to a real MongoDB server instead of a flat JSON file. You keep writing entities, collections and
queries exactly as documented in `mmo.core`'s own docs (`Entities.md` / the `entities` MCP resource) — this
package only changes *where they're stored* and, importantly, makes `Query` filters execute as real MongoDB
aggregation pipelines on the server instead of in PHP.
Read `mmo.core`'s `Entities.md` first if you haven't already — this document assumes you already know
`Entity`/`Collection`/`Query`/`Filter` from there and only covers what `mmo.mongodb` does differently.
## Configuring it
Register a `MongoRepository` the same way you'd register a `JsonRepository`, via `\MMO\config([...])` with a
`['$' => ...]` lazy object descriptor. Its constructor is `__construct(string $uri, string $database, array
$collections = [])`, so the config keys are `uri`, `database`, `collections` (matched by parameter name):
```php
\MMO\config([
MyRepositoryController::class => [
'repositories' => ['main' => ['$' => '#repo']],
],
'repo' => [
'$' => \MMO\mongodb\MongoRepository::class,
'uri' => 'mongodb://127.0.0.1:27017',
'database' => 'my_database',
'collections' => ['articles' => Article::class, '!archive' => ArchivedArticle::class],
],
]);
$repo = \MMO\get('repo'); // a real MongoRepository, connected lazily
$articles = $repo->getCollection('articles');
```
`collections` works exactly like on `Repository`/`JsonRepository`: `name => EntityClass`, with a `!` prefix
making that collection read-only. Each named collection maps 1:1 to a MongoDB collection of the same name in
`database` — no separate "collection class" is needed on the `mmo.mongodb` side, you get a normal
`MMO\core\entities\Collection`.
Requires the `mongodb` PHP extension (`ext-mongodb` / `MongoDB\Driver\*`) to be installed — `mmo.mongodb` uses
the low-level driver classes (`Manager`, `Query`, `Command`, `BulkWrite`) directly, not the higher-level
`mongodb/mongodb` composer library.
## What changes versus the base entity layer
- **Filters compile to a real MongoDB aggregation pipeline**, not a PHP loop over an in-memory array. When a
`Query` is dispatched, `MongoExporter::Export($query, ...)` turns it into a pipeline of `$lookup`/`$unwind`
(for referenced entities), `$match`, `$sort`, `$skip`/`$limit`, and `$project` stages, executed server-side.
The framework's `$`-prefixed filter operators map onto MongoDB operators as follows:
| Framework operator | Mongo translation |
|---|---|
| `$and` / `$or` / `$nor` | passed straight through (`$and`/`$or`/`$nor` are native Mongo operators too) |
| `$lt` / `$lte` / `$gt` / `$gte` / `$ne` | passed straight through unchanged |
| `$in` / `$nin` | passed straight through, after coercing each value through the entity's schema |
| `$has` (framework: "array field contains this value") | rewritten to Mongo's `$eq` (matching one element of an array field against a scalar *is* `$eq` in Mongo) |
| `$regex` | rewritten into a real `MongoDB\BSON\Regex` (accepts either a `/pattern/flags` string or a plain pattern, case-insensitive by default) |
| `$text` (framework: substring test) | pulled out of `$match` and issued as its own **first** pipeline stage using Mongo's native `$text` search — this needs a text index (see *Full-text search* below), it does not just substring-match locally |
| `$exists` (framework: "holds a value", null does not) | rewritten to `$ne: null` for true, `$eq: null` (null or missing) for false — not Mongo's own `$exists`, which counts a stored null, and a document stores every property. Its value is read as a bool: `?filters[x][$exists]=0` or `=false` from a query string too |
A plain `'field' => value'` equality and a nested `'a/b/c'` path both work as usual — nested `/` paths become
Mongo dot-notation (`a.b.c`) automatically.
- **`id` becomes Mongo's `_id`, and is always a `MongoDB\BSON\ObjectId`.** Any path that is or ends in `/id`
(`'id'`, `'author/id'`, …) is rewritten to `_id` and its value(s) coerced into `ObjectId` before the query
runs. `MongoRepository::createEntity()` does the same on the way in: pass a plain string/int and it's wrapped
into `new ObjectID(...)` for you. Don't assume `$entity->getId()` returns a string — for a Mongo-backed
entity it's a real `ObjectId` object (stringify it yourself if you need the hex form).
- **References resolve via `$lookup`/`$unwind`, not N+1 fetches.** An `Entity`-typed property stored as
`['$ref' => collectionName, '$id' => ObjectId]` is joined server-side when a query filters or sorts on a
field *inside* that referenced entity — the exporter adds the matching `$lookup` stage automatically (see
`MongoExporter::exportQuery()` / `injectMatchIntoLookup()`).
- **`$query->total` is the real total match count when pagination is active** — unlike the base
`Repository`/`JsonRepository` path (documented in `mmo.core` as a known gap: there, `total` only counts
entities actually *returned*, bounded by `limit`). `MongoRepository` uses a `$facet` pipeline stage whenever
an `offset` is set or `limit > 1`, running a separate `$count` alongside the paginated data in the same
round-trip, so `$query->total` after `dispatch()` is a true "how many rows match" count you can safely use
for page counts. This only kicks in when pagination is active; a query with no offset and `limit` of 0/1
leaves `total` unset (`null`).
- **`label`/`title` template fields computed from the schema are rematerialized as Mongo `$addFields`** when a
filter touches them, so you can match/sort on a computed `@title`/`@label` template even though it isn't a
real stored field (see `MongoExporter::addLabelAndTitleFields()` / `templateToMongoField()`).
- **Commits are batched per collection with `BulkWrite`.** `\MMO\commit(...)` still works the same way from
calling code; under the hood `MongoRepository::onCommit()` groups every changed/created/removed entity by
collection into one `BulkWrite` each (insert/update/delete), executed against the primary. Updates use
`$set` with only the entity's *changed* properties unless you pass `['replace' => true]` as a commit option,
which replaces the whole document instead.
## Automatic query/mutation logging
If you register a collection for `MMO\core\entities\LogQuery` (an `Entity` subclass shipped by `mmo.core`),
`MongoRepository` automatically writes an audit-log entry (insert/update/delete/status-change) into it as part
of the same commit — one more `BulkWrite` batch alongside the data being written. This is opt-in: just add a
`LogQuery`-mapped collection to the repository's `collections` config; if you don't, nothing is logged.
## Full-text search
`autoTextIndexes()` inspects every registered collection's schema and creates a single compound Mongo *text*
index per collection (named `mmo_auto_text_index`) covering every string `#[ArrayProperty]` and the computed
`label`/`title` template fields, using `LANGUAGE`'s first two characters as the default index language.
`searchText($collectionOrQuery, $text)` then runs a `$text: {$search: ...}` query against that index, and
additionally follows any `iEntity`-typed properties recursively into their own collections, merging results —
this is what a base `Repository` does *not* implement (`aRepository::searchText()` just throws `eRuntime`).
Call `dropAutoTextIndexes()` before re-running `autoTextIndexes()` if your schema changed and you need to
rebuild the index.
## Backup / restore / export / shell helpers
`MongoRepository` wraps the `mongodump`/`mongorestore`/`mongosh` CLI tools (via `Shell::Shared()` or an
`iShell` you pass in, so this also works against a remote host over SSH):
- `backup()` / `restore()` — `mongodump --archive=...` / `mongorestore --archive=...`, filenames resolved
through `\MMO\filename()` under `backupFolder` (`'{root}/db/backups'` by default).
- `patch($database, $jsEval, $shell = null)` — runs a raw `mongosh --eval=...` snippet against a database.
**Not a safe way to run untrusted input**: the snippet goes through `\MMO\core\shell\escape()`, which
single-quotes it — except a snippet that already starts and ends with `'`, which it returns unchanged and so
leaves unprotected — only ever pass trusted, literal JS.
- `collectionExists()`, `listCollections()`, `getDistinct()`, `dropCollection()`, `dropDatabase()`,
`getDatabases()`, `createIndex()`/`createIndexes()`/`dropIndexes()`/`listIndexes()`/`hasIndex()` — thin
wrappers around the matching Mongo admin commands.
`MongoController` (`MMO\mongodb\MongoController extends \MMO\core\classes\aController`) exposes some of this
over HTTP if you register it: `GET mongo/export/{db}/{collection}.json` (shells out to `mongoexport`), `POST
mongo/import` (shells out to `mongoimport`), and `GET mongo/{repo}/backup.zip` (streams `$repo->backup()`'s
output). **Every route requires an `admin.mongo` scope check via `\MMO\oauth\isUserAllowed(...)`** — this
controller has a hard, undeclared runtime dependency on the `mmo.oauth` package (`mmo.json` only lists
`mmo.core` as a dependency). If `mmo.oauth` isn't installed/loaded in your application, don't register
`MongoController` — its route handlers reference `MMO\oauth\...` classes directly and will fatal.
## Gotchas
- **The package's own `README.md` is not general package documentation.** It's a mix of a generic "how to
install MongoDB" guide and a long list of ad-hoc, client-specific `mongosh` migration scripts (real
collection/field renames, hardcoded `ObjectId`s, real client/company names). It was deliberately **not**
wired up as the `readme` in `mmo.json`'s showcase metadata for that reason — this `Overview.md` is the
intended entry point for an AI agent instead. If a clean, generic README is written later, point `mmo.json`'s
`readme` field at it.
- No `icon.svg` currently ships with this package (unlike `mmo.core`/`mmo.client`), so `mmo.json` leaves the
`icon` field out rather than pointing at a nonexistent file.
- `patch()`'s "escaping" (`\MMO\core\shell\escape()`) is not an injection boundary: it leaves a snippet that
starts and ends with `'` unquoted — see above.
- Pagination triggers a heavier `$facet` pipeline (a documented, intentional tradeoff — see the class-level
docblock on `MongoRepository`): only set an `offset`/`limit > 1` when you actually need paging or the true
total count, not on every listing query.