Index
# 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.