diff --git a/CHANGELOG.md b/CHANGELOG.md index 975a165..f1721cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to this project are documented in this file. ## [Unreleased] ### Added +- Added `listCollections`, `drop`, and `dropDatabase` command handlers for metadata lookup and fixture cleanup flows. +- Added UTF `runCommand` importer support for `listCollections`, `distinct`, `drop`, and `dropDatabase`. +- Added tier-0 TTL runtime pruning for single-field `expireAfterSeconds` indexes using deterministic lazy cleanup. - Added regression coverage for `findOneAndUpdate`/upsert duplicate-key behavior when `_id` matches an existing document but other predicates do not, including `$and`-wrapped filters. - Added declarative fixture manifest schema (`fixture-manifest.v1`) with standardized `dev`/`smoke`/`full` profile model. - Added fixture manifest loader/validator with path-aware aggregated validation errors. @@ -44,8 +47,12 @@ All notable changes to this project are documented in this file. - Added structured runtime logging contract (`onLog`, `logFormat`) with expanded log levels (`error`/`warn`/`info`/`debug`). ### Fixed +- Fixed deterministic R3 parity mismatches for dollar-prefixed `_id` subfields, replacement-style `updateMany`, and `createIndexes` inside transactions. - Fixed upsert seed extraction to honor equality clauses nested inside `$and`, restoring duplicate-key behavior for `findOneAndUpdate` lock-style filters. +### Changed +- Default local project version moved to `0.1.6-SNAPSHOT`. + ## [0.1.3] - 2026-02-24 ### Added diff --git a/README.md b/README.md index a27afd4..f474867 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ This project targets integration-test compatibility for common Spring data paths | Area | Current level | Notes | | --- | --- | --- | -| Command surface | 22 handlers | Mix of `Supported` and `Partial` | +| Command surface | 25 handlers | Mix of `Supported` and `Partial` | | Query language | Core comparison/logical/array/regex + partial `$expr` (including `$add` subset) | Advanced parity incomplete | | Aggregation | Core stages + selected Tier-2 stages + minimal `$graphLookup` subset | Full operator coverage not implemented | | Transactions | Single-process session/transaction flow | Namespace-aware commit merge + snapshot reads (`find`/`aggregate`/`countDocuments`) + deterministic retry labels/contracts | @@ -183,10 +183,10 @@ This project targets integration-test compatibility for common Spring data paths Support manifest summary: - `Supported`: 7 -- `Partial`: 6 +- `Partial`: 7 - `Unsupported`: 0 -Latest certification evidence (2026-02-28): +Historical certification evidence (2026-02-28; refresh on the release-candidate commit before tagging): - Official Suite Sharded: run `22516323868` (`total=508`, `mismatch=0`, `error=0`) - R3 Failure Ledger: run `22516324202` (`failureCount=0`) - Complex Query Certification: run `22516137734` (`packVersion=complex-query-pack-v3`, `mismatchCount=0`, `unsupportedByPolicyCount=0`) @@ -224,15 +224,16 @@ static void mongoProps(DynamicPropertyRegistry registry) { ## Implemented Command Handlers - `hello`, `isMaster`, `ping`, `buildInfo`, `getParameter` -- `insert`, `find`, `aggregate`, `getMore`, `killCursors` -- `createIndexes`, `listIndexes`, `update`, `delete`, `bulkWrite` +- `insert`, `find`, `distinct`, `aggregate`, `getMore`, `killCursors` +- `createIndexes`, `listIndexes`, `listCollections`, `drop`, `dropDatabase` +- `update`, `delete`, `bulkWrite` - `findAndModify`, `countDocuments`, `replaceOne`, `findOneAndUpdate`, `findOneAndReplace` - `commitTransaction`, `abortTransaction` ## Known Limitations - partial query and aggregation parity -- collation/TTL are currently metadata-focused, not full runtime parity +- collation and TTL runtime semantics are subset-only, not full server parity - limited update operator coverage - no multi-node replica-set or sharded topology semantics diff --git a/build.gradle.kts b/build.gradle.kts index b7ea4a6..0cb6552 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ plugins { } group = providers.gradleProperty("publishGroup").orElse("io.github.midagedev").get() -version = providers.gradleProperty("publishVersion").orElse("0.1.3-SNAPSHOT").get() +version = providers.gradleProperty("publishVersion").orElse("0.1.6-SNAPSHOT").get() repositories { mavenCentral() diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 54adc66..419b3ac 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -1,6 +1,6 @@ # Compatibility Matrix -Status date: 2026-02-28 +Status date: 2026-05-09 This page describes implemented behavior in this repository. It is a code-level matrix, not a MongoDB claim. @@ -25,13 +25,16 @@ Certification context: | `killCursors` | Supported | Cursor cancellation | | `createIndexes` | Partial | Key metadata accepted; runtime semantics partial | | `listIndexes` | Partial | Metadata round-trip | +| `listCollections` | Partial | Cursor-shaped collection metadata subset for fixture discovery | +| `drop` | Partial | Collection cleanup subset with deterministic `NamespaceNotFound` error | +| `dropDatabase` | Partial | Database-scoped cleanup subset | | `update` | Partial | Operator set intentionally limited; update pipeline subset supports `$set`/`$unset` stages without expression evaluation | | `delete` | Supported | `limit` 0/1 behavior | | `bulkWrite` | Partial | Ordered mode only (`ordered=true`); supports `insertOne/updateOne/updateMany/deleteOne/deleteMany/replaceOne` and stops on first write error | | `clientBulkWrite` | Partial | UTF importer subset rewrites ordered single-namespace models to `bulkWrite`; mixed namespaces, `ordered=false`, and `verboseResults=true` are deterministic unsupported paths | | `count` | Partial | Alias path routed through `countDocuments` semantics in the test-backend profile | | `countDocuments` | Partial | Filter + skip/limit + hint/readConcern; collation subset applied to filter comparison | -| `runCommand` | Partial | UTF importer subset supports `ping`, `buildInfo`, `listIndexes`, `count`; other command names fail with deterministic unsupported reasons | +| `runCommand` | Partial | UTF importer subset supports `ping`, `buildInfo`, `listIndexes`, `listCollections`, `count`, `distinct`, `drop`, `dropDatabase`; other command names fail with deterministic unsupported reasons | | `replaceOne` | Partial | Rewrites to single replacement `update` path (`multi=false`) | | `findOneAndUpdate` | Partial | Rewrites to `findAndModify`; supports operator updates plus update-pipeline subset (`$set`/`$unset`, no expression evaluation), `arrayFilters` subset, and projection include/exclude subset (including `_id` override) | | `findOneAndReplace` | Partial | Rewrites to `findAndModify`; replacement updates only; supports projection include/exclude subset (including `_id` override) | @@ -80,13 +83,14 @@ Implemented stages: - `$unionWith` - `$graphLookup` (minimal subset: `from`, `startWith`, `connectFromField`, `connectToField`, `as`, optional `maxDepth`) - `$out` (terminal string-target subset: replaces target collection contents and returns empty result set) +- `$merge` (terminal string-target or `{into: }` subset; merges by `_id`) Not implemented or partial: - unsupported stages return deterministic fail-fast - many advanced expression operators are still missing - `$group` accumulators other than `$sum` are not available - `$unwind.includeArrayIndex` is not available -- `$merge` stage is excluded from current differential corpus +- `$merge` options beyond the terminal string / `{into: }` subset are deterministic unsupported paths - `$graphLookup` options outside current subset (for example `depthField`, `restrictSearchWithMatch`) are deterministic unsupported paths - `bypassDocumentValidation` for aggregate is excluded from current differential corpus @@ -116,8 +120,8 @@ differential parity counts: - unordered `insertMany` (`ordered=false`) - unordered `bulkWrite` (`ordered=false`) - `clientBulkWrite` with mixed namespaces, `ordered=false`, or `verboseResults=true` -- documents containing dot or dollar-prefixed field paths in insert payloads -- `runCommand` command names outside the imported subset (`ping`, `buildInfo`, `listIndexes`, `count`) +- dot/dollar insert payload forms outside the deterministic insert subset; dollar-prefixed subfields under `_id` fail with `code=52` +- `runCommand` command names outside the imported subset (`ping`, `buildInfo`, `listIndexes`, `listCollections`, `count`, `distinct`, `drop`, `dropDatabase`) - update operations using unsupported `arrayFilters` forms (outside `$set`/`$unset` subset) - update pipeline forms outside the supported subset (`$set`/`$unset` stages with literal values) - replacement updates requested with `multi=true` @@ -140,10 +144,11 @@ Supported metadata paths: - `partialFilterExpression` - `collation` metadata - `expireAfterSeconds` metadata +- tier-0 TTL pruning for single-field non-partial TTL indexes Current limitations: - collation runtime semantics are subset-only (`locale`/`strength`/`caseLevel`) -- TTL expiration loop/clock behavior is not fully implemented +- TTL runtime behavior is lazy, in-process, and limited to date-like values on single-field non-partial indexes ## Transactions @@ -183,6 +188,9 @@ Not in scope for this profile: Known deterministic error classes: - unknown command: `CommandNotFound` (`code=59`) - validation failures: `BadValue` / `TypeMismatch` (`code=14`) +- parse parity failures: `FailedToParse` (`code=9`) +- invalid dollar-prefixed `_id` subfields: `DollarPrefixedFieldName` (`code=52`) +- missing namespace on cleanup commands: `NamespaceNotFound` (`code=26`) - duplicate index/key: `DuplicateKey` (`code=11000`) - cursor lifecycle: `CursorNotFound` (`code=43`) - transaction state: `NoSuchTransaction` (`code=251`) diff --git a/docs/COMPATIBILITY_SCORECARD.md b/docs/COMPATIBILITY_SCORECARD.md index af5d79a..5e05621 100644 --- a/docs/COMPATIBILITY_SCORECARD.md +++ b/docs/COMPATIBILITY_SCORECARD.md @@ -1,8 +1,8 @@ # Compatibility Scorecard -Status date: 2026-02-28 +Status date: 2026-05-09 -This scorecard tracks MongoDB UTF compatibility progress with a focus on `runOnRequirements not satisfied` reduction while preserving differential stability (`mismatch=0`, `error=0`). +This scorecard tracks MongoDB UTF compatibility progress with a focus on `runOnRequirements not satisfied` reduction while preserving differential stability. Historical checkpoints below may not match the latest release-candidate artifacts; refresh this page from the RC workflow outputs before tagging. ## Lane model @@ -57,8 +57,8 @@ Execution model: Gates: -- strict lane stability: strict `mismatch=0` and `error=0` -- extended no-regression: extended mismatch/error cannot exceed strict +- strict lane mismatch/error deltas are reported as warnings during routine pilot runs +- extended mismatch/error deltas are reported as warnings during routine pilot runs - runOn reduction: extended `runOnNotSatisfied` must be `<=` strict - coverage non-regression: extended imported must be `>=` strict diff --git a/docs/NODE_COMPAT_SMOKE.md b/docs/NODE_COMPAT_SMOKE.md index 4bfc249..4c971b9 100644 --- a/docs/NODE_COMPAT_SMOKE.md +++ b/docs/NODE_COMPAT_SMOKE.md @@ -1,16 +1,28 @@ # Node Compatibility Smoke -Status date: 2026-02-24 +Status date: 2026-05-09 This suite validates practical Node ecosystem compatibility on top of `@jongodb/memory-server`: - official `mongodb` Node driver +- Express route handler using the official driver +- Koa route handler using the official driver +- TypeORM MongoDB repository +- Prisma MongoDB provider - `mongoose` Current scenario set: -- CRUD baseline -- transaction commit -- transaction rollback +- `mongodb.crud` +- `express.mongodb.route` +- `koa.mongodb.route` +- `typeorm.mongodb.repository` +- `prisma.mongodb.provider` +- `mongodb.transaction.commit` +- `mongodb.transaction.rollback` +- `mongoose.crud` +- `mongoose.session.with-transaction` +- `mongoose.transaction.commit` +- `mongoose.transaction.rollback` Execution: - local command: `npm --prefix testkit/node-compat test` diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 8f3aac9..80c5682 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -1,12 +1,12 @@ # Release Checklist -Status date: 2026-02-28 +Status date: 2026-05-09 ## R3 Certification Sign-Off -- [x] Official suite sharded run on `main` completed with zero mismatch/error. +- [ ] Official suite sharded run on the release-candidate commit completed with zero mismatch/error. - run id: `22516323868` (commit `d2ba61b`) -- [x] R3 failure ledger run on `main` completed with `failureCount=0`. +- [ ] R3 failure ledger run on the release-candidate commit completed with `failureCount=0`. - run id: `22516324202` (commit `d2ba61b`) - [x] External canary certification completed for 3 projects with rollback success. - latest success run id: `22378993613` (commit `868bbc7`) @@ -20,14 +20,14 @@ Status date: 2026-02-28 - [x] Support boundary documents are updated. - `docs/SUPPORT_MATRIX.md` - `docs/COMPATIBILITY_SCORECARD.md` -- [x] README certification snapshot is updated and links current evidence docs. +- [ ] README certification snapshot is updated and links current release-candidate evidence docs. ## Current Release-Line Notes - Latest Java release tag: `v0.1.5` (run `22379241855`, commit `868bbc7`). - Latest Node adapter tag: `node-v0.1.4` (run `22379340586`, commit `868bbc7`). - Latest compatibility certification snapshot: commit `d2ba61b` (runs `22516323868`, `22516324202`, `22516137734`). -- Next tag candidate should regenerate certification artifacts against the release-candidate commit, not reuse historical tag evidence. +- Next Java tag candidate: `v0.1.6`; regenerate certification artifacts against the release-candidate commit, not reuse historical tag evidence. ## Tagging Gate diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b1349da..0525968 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -46,26 +46,29 @@ Completed in current wave: - minimal `$graphLookup` certification subset implemented with deterministic option boundary - listLocalSessions regression coverage normalized in UTF importer regression pack - release-readiness streak artifacts added (`utf-shard-streak`, `r3-release-readiness-streak`) +- `listCollections`, `drop`, and `dropDatabase` command subsets added for fixture discovery/cleanup +- tier-0 TTL runtime pruning added for single-field non-partial TTL indexes +- deterministic R3 ledger mismatches reduced for `_id` dollar subfields, `updateMany` replacement validation, and transactional `createIndexes` ## Current Focus ### Pre-Release Hardening Target outcomes: -- keep imported differential parity at zero mismatch/error while reducing unsupported categories +- keep imported differential parity moving toward zero mismatch/error while reducing unsupported categories - close release-readiness streak gate (`minStreak=3`) on scheduled runs - refresh certification evidence on release-candidate commit before tagging Primary measures: -- preserve `mismatch=0`, `error=0` across Official Suite and R3 Ledger +- preserve or restore `mismatch=0`, `error=0` across release-candidate Official Suite and R3 Ledger runs - increase zero-result streak counters (`officialZeroMismatchStreak`, `r3LedgerZeroFailureStreak`) - maintain green canary certification for at least 3 external projects ## Planned Next -- close remaining aggregate-stage unsupported surface (`$merge` and advanced non-alias stages) +- close remaining aggregate-stage unsupported surface beyond the current `$merge` terminal subset - expand collation semantics beyond current subset (`locale`/`strength`/`caseLevel`) -- implement TTL runtime behavior beyond index metadata registration +- expand TTL runtime behavior beyond the current single-field lazy-prune subset - expand update/operator coverage beyond current `arrayFilters` subset (advanced positional/pipeline expressions) - expand supported transaction operations in unified suites while preserving deterministic behavior diff --git a/docs/SUPPORT_MATRIX.md b/docs/SUPPORT_MATRIX.md index 1af7333..e981e4d 100644 --- a/docs/SUPPORT_MATRIX.md +++ b/docs/SUPPORT_MATRIX.md @@ -1,6 +1,6 @@ # Support Matrix -Status date: 2026-02-28 +Status date: 2026-05-09 This matrix is a versioned support boundary for integration-test usage. Source artifact: `build/reports/r2-compatibility/r2-support-manifest.json`. @@ -10,7 +10,7 @@ Source artifact: `build/reports/r2-compatibility/r2-support-manifest.json`. | Status | Count | | --- | --- | | Supported | 7 | -| Partial | 6 | +| Partial | 7 | | Unsupported | 0 | ## Feature-Level Matrix @@ -26,6 +26,7 @@ Source artifact: `build/reports/r2-compatibility/r2-support-manifest.json`. | `index.unique-sparse-partial` | index | Supported | Unique/sparse/partial | | `index.collation-metadata` | index | Supported | Collation metadata round-trip | | `index.collation-semantic` | index | Partial | Subset: locale/strength/caseLevel on query-sort-distinct and unique index checks | +| `index.ttl-runtime-tier0` | index | Partial | Lazy pruning for single-field non-partial TTL indexes with date-like values | | `transactions-single-session` | transaction | Supported | Session + txn flow with namespace-aware commit merge and deterministic same-`_id` resolution | | `transactions-retryable-advanced` | transaction | Partial | Deterministic retry contract for commit/abort replay and transaction error-label semantics (`TransientTransactionError`, `UnknownTransactionCommitResult`) within single-process scope | | `deployment.single-node-replicaset-profile` | deployment | Partial | Optional single-node replica-set semantic profile (replica-set URI/hello shape, primary-only readPreference contract, constrained concern levels) | diff --git a/docs/USAGE.md b/docs/USAGE.md index 3251f60..42e9aa9 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -131,7 +131,7 @@ OpMsg res = codec.decode(ingress.handle(codec.encode(req))); ## Verification and Evidence Tasks -Current certified reference runs (as of 2026-02-28): +Historical certified reference runs (as of 2026-02-28; refresh on the release-candidate commit before tagging): - Official Suite Sharded: `22516323868` (`total=508`, `mismatch=0`, `error=0`) - R3 Failure Ledger: `22516324202` (`failureCount=0`) - Complex Query Certification: `22516137734` (`packVersion=complex-query-pack-v3`) @@ -287,7 +287,7 @@ Manual fallback command: ```bash gradle \ - -PpublishVersion=0.1.5 \ + -PpublishVersion=0.1.6 \ -PpublishGroup=io.github.midagedev \ -PpublishArtifactId=jongodb \ centralRelease diff --git a/src/main/java/org/jongodb/command/CommandDispatcher.java b/src/main/java/org/jongodb/command/CommandDispatcher.java index 36d8167..a4c261b 100644 --- a/src/main/java/org/jongodb/command/CommandDispatcher.java +++ b/src/main/java/org/jongodb/command/CommandDispatcher.java @@ -49,11 +49,14 @@ public CommandDispatcher( configuredHandlers.put("insert", new InsertCommandHandler(routedStore)); configuredHandlers.put("find", new FindCommandHandler(routedStore, cursorRegistry)); configuredHandlers.put("distinct", new DistinctCommandHandler(routedStore)); + configuredHandlers.put("listcollections", new ListCollectionsCommandHandler(routedStore, cursorRegistry)); configuredHandlers.put("aggregate", new AggregateCommandHandler(routedStore, cursorRegistry)); configuredHandlers.put("getmore", new GetMoreCommandHandler(cursorRegistry)); configuredHandlers.put("killcursors", new KillCursorsCommandHandler(cursorRegistry)); configuredHandlers.put("createindexes", new CreateIndexesCommandHandler(routedStore)); configuredHandlers.put("listindexes", new ListIndexesCommandHandler(routedStore, cursorRegistry)); + configuredHandlers.put("drop", new DropCommandHandler(routedStore)); + configuredHandlers.put("dropdatabase", new DropDatabaseCommandHandler(routedStore)); configuredHandlers.put("update", new UpdateCommandHandler(routedStore)); configuredHandlers.put("delete", new DeleteCommandHandler(routedStore)); configuredHandlers.put("bulkwrite", new BulkWriteCommandHandler(routedStore)); @@ -175,6 +178,21 @@ public int insert(final String database, final String collection, final java.uti return delegate().insert(database, collection, documents); } + @Override + public java.util.List listCollections(final String database) { + return delegate().listCollections(database); + } + + @Override + public DropCollectionResult dropCollection(final String database, final String collection) { + return delegate().dropCollection(database, collection); + } + + @Override + public int dropDatabase(final String database) { + return delegate().dropDatabase(database); + } + @Override public java.util.List find( final String database, final String collection, final BsonDocument filter) { diff --git a/src/main/java/org/jongodb/command/CommandErrors.java b/src/main/java/org/jongodb/command/CommandErrors.java index 9c1c7fd..8a104b5 100644 --- a/src/main/java/org/jongodb/command/CommandErrors.java +++ b/src/main/java/org/jongodb/command/CommandErrors.java @@ -12,8 +12,11 @@ */ final class CommandErrors { private static final int CODE_COMMAND_NOT_FOUND = 59; + private static final int CODE_DOLLAR_PREFIXED_FIELD_NAME = 52; + private static final int CODE_FAILED_TO_PARSE = 9; private static final int CODE_INVALID_ARGUMENT = 14; private static final int CODE_NOT_IMPLEMENTED = 238; + private static final int CODE_NAMESPACE_NOT_FOUND = 26; private static final int CODE_CURSOR_NOT_FOUND = 43; private static final int CODE_NO_SUCH_TRANSACTION = 251; private static final int CODE_TRANSACTION_COMMITTED = 256; @@ -30,10 +33,21 @@ static BsonDocument badValue(final String message) { return error(message, CODE_INVALID_ARGUMENT, "BadValue"); } + static BsonDocument failedToParse(final String message) { + return error(message, CODE_FAILED_TO_PARSE, "FailedToParse"); + } + static BsonDocument typeMismatch(final String message) { return error(message, CODE_INVALID_ARGUMENT, "TypeMismatch"); } + static BsonDocument dollarPrefixedIdField(final String fieldName) { + return error( + "_id fields may not contain '$'-prefixed fields: " + fieldName + " is not valid for storage", + CODE_DOLLAR_PREFIXED_FIELD_NAME, + "DollarPrefixedFieldName"); + } + static BsonDocument notImplemented(final String message) { return errorWithLabels(message, CODE_NOT_IMPLEMENTED, "NotImplemented", List.of("UnsupportedFeature")); } @@ -70,6 +84,10 @@ static BsonDocument duplicateKey(final String message) { return error(message, CODE_DUPLICATE_KEY, "DuplicateKey"); } + static BsonDocument namespaceNotFound(final String namespace) { + return error("ns not found: " + namespace, CODE_NAMESPACE_NOT_FOUND, "NamespaceNotFound"); + } + static BsonDocument cursorNotFound(final long cursorId) { return error("cursor not found: " + cursorId, CODE_CURSOR_NOT_FOUND, "CursorNotFound"); } diff --git a/src/main/java/org/jongodb/command/CommandStore.java b/src/main/java/org/jongodb/command/CommandStore.java index 0aec2c9..40ed122 100644 --- a/src/main/java/org/jongodb/command/CommandStore.java +++ b/src/main/java/org/jongodb/command/CommandStore.java @@ -8,6 +8,18 @@ public interface CommandStore { int insert(String database, String collection, List documents); + default List listCollections(final String database) { + return List.of(); + } + + default DropCollectionResult dropCollection(final String database, final String collection) { + return new DropCollectionResult(false, 0); + } + + default int dropDatabase(final String database) { + return 0; + } + List find(String database, String collection, BsonDocument filter); default List find( @@ -160,6 +172,10 @@ private static List copyArrayFilters(final List sour record DeleteRequest(BsonDocument query, int limit) {} + record CollectionMetadata(String name) {} + + record DropCollectionResult(boolean dropped, int nIndexesWas) {} + record Upserted(int index, BsonValue id) {} record UpdateResult(int matchedCount, int modifiedCount, List upserted) { diff --git a/src/main/java/org/jongodb/command/DropCommandHandler.java b/src/main/java/org/jongodb/command/DropCommandHandler.java new file mode 100644 index 0000000..956575e --- /dev/null +++ b/src/main/java/org/jongodb/command/DropCommandHandler.java @@ -0,0 +1,55 @@ +package org.jongodb.command; + +import org.bson.BsonDocument; +import org.bson.BsonDouble; +import org.bson.BsonInt32; +import org.bson.BsonString; +import org.bson.BsonValue; + +public final class DropCommandHandler implements CommandHandler { + private final CommandStore store; + + public DropCommandHandler(final CommandStore store) { + this.store = store; + } + + @Override + public BsonDocument handle(final BsonDocument command) { + final String database = readDatabase(command); + final String collection = readRequiredString(command, "drop"); + if (collection == null) { + return CommandErrors.typeMismatch("drop must be a string"); + } + + final BsonDocument optionError = CrudCommandOptionValidator.validateWriteConcern(command); + if (optionError != null) { + return optionError; + } + + final CommandStore.DropCollectionResult result = store.dropCollection(database, collection); + final String namespace = database + "." + collection; + if (!result.dropped()) { + return CommandErrors.namespaceNotFound(namespace); + } + return new BsonDocument() + .append("nIndexesWas", new BsonInt32(result.nIndexesWas())) + .append("ns", new BsonString(namespace)) + .append("ok", new BsonDouble(1.0)); + } + + private static String readDatabase(final BsonDocument command) { + final BsonValue value = command.get("$db"); + if (value == null || !value.isString()) { + return "test"; + } + return value.asString().getValue(); + } + + private static String readRequiredString(final BsonDocument command, final String key) { + final BsonValue value = command.get(key); + if (!(value instanceof BsonString bsonString)) { + return null; + } + return bsonString.getValue(); + } +} diff --git a/src/main/java/org/jongodb/command/DropDatabaseCommandHandler.java b/src/main/java/org/jongodb/command/DropDatabaseCommandHandler.java new file mode 100644 index 0000000..706282e --- /dev/null +++ b/src/main/java/org/jongodb/command/DropDatabaseCommandHandler.java @@ -0,0 +1,37 @@ +package org.jongodb.command; + +import org.bson.BsonDocument; +import org.bson.BsonDouble; +import org.bson.BsonString; +import org.bson.BsonValue; + +public final class DropDatabaseCommandHandler implements CommandHandler { + private final CommandStore store; + + public DropDatabaseCommandHandler(final CommandStore store) { + this.store = store; + } + + @Override + public BsonDocument handle(final BsonDocument command) { + final String database = readDatabase(command); + + final BsonDocument optionError = CrudCommandOptionValidator.validateWriteConcern(command); + if (optionError != null) { + return optionError; + } + + store.dropDatabase(database); + return new BsonDocument() + .append("dropped", new BsonString(database)) + .append("ok", new BsonDouble(1.0)); + } + + private static String readDatabase(final BsonDocument command) { + final BsonValue value = command.get("$db"); + if (value == null || !value.isString()) { + return "test"; + } + return value.asString().getValue(); + } +} diff --git a/src/main/java/org/jongodb/command/EngineBackedCommandStore.java b/src/main/java/org/jongodb/command/EngineBackedCommandStore.java index 46c6e76..2a20a3a 100644 --- a/src/main/java/org/jongodb/command/EngineBackedCommandStore.java +++ b/src/main/java/org/jongodb/command/EngineBackedCommandStore.java @@ -109,6 +109,29 @@ public int insert(final String database, final String collection, final List listCollections(final String database) { + final List collectionNames = engineStore.listCollectionNames(database); + final List converted = new ArrayList<>(collectionNames.size()); + for (final String collectionName : collectionNames) { + converted.add(new CollectionMetadata(collectionName)); + } + return List.copyOf(converted); + } + + @Override + public DropCollectionResult dropCollection(final String database, final String collection) { + final int nIndexesWas = engineStore.collectionExists(database, collection) + ? engineStore.collection(database, collection).listIndexes().size() + : 0; + return new DropCollectionResult(engineStore.dropCollection(database, collection), nIndexesWas); + } + + @Override + public int dropDatabase(final String database) { + return engineStore.dropDatabase(database); + } + @Override public List find(final String database, final String collection, final BsonDocument filter) { return find(database, collection, filter, CollationSupport.Config.simple()); @@ -540,6 +563,21 @@ public int insert(final String database, final String collection, final List listCollections(final String database) { + return activeReadDelegate().listCollections(database); + } + + @Override + public DropCollectionResult dropCollection(final String database, final String collection) { + return materializeWriteDelegate().dropCollection(database, collection); + } + + @Override + public int dropDatabase(final String database) { + return materializeWriteDelegate().dropDatabase(database); + } + @Override public List find(final String database, final String collection, final BsonDocument filter) { return activeReadDelegate().find(database, collection, filter); diff --git a/src/main/java/org/jongodb/command/InsertCommandHandler.java b/src/main/java/org/jongodb/command/InsertCommandHandler.java index 0b97803..4e6b752 100644 --- a/src/main/java/org/jongodb/command/InsertCommandHandler.java +++ b/src/main/java/org/jongodb/command/InsertCommandHandler.java @@ -49,7 +49,12 @@ public BsonDocument handle(final BsonDocument command) { if (!value.isDocument()) { return CommandErrors.typeMismatch("all entries in documents must be BSON documents"); } - documents.add(value.asDocument()); + final BsonDocument document = value.asDocument(); + final BsonDocument idValidationError = validateIdDocument(document); + if (idValidationError != null) { + return idValidationError; + } + documents.add(document); } final int insertedCount; @@ -78,4 +83,17 @@ private static String readRequiredString(final BsonDocument command, final Strin } return bsonString.getValue(); } + + private static BsonDocument validateIdDocument(final BsonDocument document) { + final BsonValue idValue = document.get("_id"); + if (idValue == null || !idValue.isDocument()) { + return null; + } + for (final String key : idValue.asDocument().keySet()) { + if (key.startsWith("$")) { + return CommandErrors.dollarPrefixedIdField(key); + } + } + return null; + } } diff --git a/src/main/java/org/jongodb/command/ListCollectionsCommandHandler.java b/src/main/java/org/jongodb/command/ListCollectionsCommandHandler.java new file mode 100644 index 0000000..53369e3 --- /dev/null +++ b/src/main/java/org/jongodb/command/ListCollectionsCommandHandler.java @@ -0,0 +1,177 @@ +package org.jongodb.command; + +import java.util.ArrayList; +import java.util.List; +import org.bson.BsonArray; +import org.bson.BsonBoolean; +import org.bson.BsonDocument; +import org.bson.BsonDouble; +import org.bson.BsonInt32; +import org.bson.BsonInt64; +import org.bson.BsonString; +import org.bson.BsonValue; + +public final class ListCollectionsCommandHandler implements CommandHandler { + private final CommandStore store; + private final CursorRegistry cursorRegistry; + + public ListCollectionsCommandHandler(final CommandStore store, final CursorRegistry cursorRegistry) { + this.store = store; + this.cursorRegistry = cursorRegistry; + } + + @Override + public BsonDocument handle(final BsonDocument command) { + final String database = readDatabase(command); + + final BsonValue filterValue = command.get("filter"); + final BsonDocument filter; + if (filterValue == null) { + filter = new BsonDocument(); + } else if (filterValue.isDocument()) { + filter = filterValue.asDocument(); + } else { + return CommandErrors.typeMismatch("filter must be a document"); + } + + final BsonValue nameOnlyValue = command.get("nameOnly"); + final boolean nameOnly; + if (nameOnlyValue == null) { + nameOnly = false; + } else if (!nameOnlyValue.isBoolean()) { + return CommandErrors.typeMismatch("nameOnly must be a boolean"); + } else { + nameOnly = nameOnlyValue.asBoolean().getValue(); + } + + final BsonValue authorizedCollectionsValue = command.get("authorizedCollections"); + if (authorizedCollectionsValue != null && !authorizedCollectionsValue.isBoolean()) { + return CommandErrors.typeMismatch("authorizedCollections must be a boolean"); + } + + final CursorBatch cursorBatch = parseCursorBatch(command.get("cursor")); + if (cursorBatch.error() != null) { + return cursorBatch.error(); + } + + final List collectionDocuments = new ArrayList<>(); + for (final CommandStore.CollectionMetadata collection : store.listCollections(database)) { + final BsonDocument metadata = collectionDocument(collection.name(), nameOnly); + if (matchesFilter(metadata, filter)) { + collectionDocuments.add(metadata); + } + } + + final String namespace = database + ".$cmd.listCollections"; + final CursorRegistry.FindRegistration registration = + cursorRegistry.openCursor(namespace, List.copyOf(collectionDocuments), cursorBatch.batchSize()); + final BsonArray firstBatch = new BsonArray(); + for (final BsonDocument document : registration.firstBatch()) { + firstBatch.add(document); + } + + return new BsonDocument() + .append("cursor", new BsonDocument() + .append("id", new BsonInt64(registration.cursorId())) + .append("ns", new BsonString(namespace)) + .append("firstBatch", firstBatch)) + .append("ok", new BsonDouble(1.0)); + } + + private static BsonDocument collectionDocument(final String name, final boolean nameOnly) { + final BsonDocument document = new BsonDocument() + .append("name", new BsonString(name)) + .append("type", new BsonString("collection")); + if (!nameOnly) { + document.append("options", new BsonDocument()) + .append("info", new BsonDocument("readOnly", BsonBoolean.FALSE)) + .append("idIndex", new BsonDocument() + .append("v", new BsonInt32(2)) + .append("key", new BsonDocument("_id", new BsonInt32(1))) + .append("name", new BsonString("_id_"))); + } + return document; + } + + private static boolean matchesFilter(final BsonDocument document, final BsonDocument filter) { + if (filter == null || filter.isEmpty()) { + return true; + } + for (final String key : filter.keySet()) { + final BsonValue expected = filter.get(key); + final BsonValue actual = valueAtPath(document, key); + if (actual == null || !actual.equals(expected)) { + return false; + } + } + return true; + } + + private static BsonValue valueAtPath(final BsonDocument document, final String path) { + if (path == null || path.isEmpty()) { + return null; + } + BsonValue current = document; + for (final String segment : path.split("\\.")) { + if (!current.isDocument()) { + return null; + } + current = current.asDocument().get(segment); + if (current == null) { + return null; + } + } + return current; + } + + private static CursorBatch parseCursorBatch(final BsonValue cursorValue) { + if (cursorValue == null) { + return new CursorBatch(Integer.MAX_VALUE, null); + } + if (!cursorValue.isDocument()) { + return new CursorBatch(0, CommandErrors.typeMismatch("cursor must be a document")); + } + final BsonValue batchSizeValue = cursorValue.asDocument().get("batchSize"); + if (batchSizeValue == null) { + return new CursorBatch(Integer.MAX_VALUE, null); + } + final Long parsedBatchSize = readIntegralLong(batchSizeValue); + if (parsedBatchSize == null) { + return new CursorBatch(0, CommandErrors.typeMismatch("cursor.batchSize must be an integer")); + } + if (parsedBatchSize < 0 || parsedBatchSize > Integer.MAX_VALUE) { + return new CursorBatch(0, CommandErrors.badValue("cursor.batchSize must be a non-negative integer")); + } + return new CursorBatch(parsedBatchSize.intValue(), null); + } + + private static String readDatabase(final BsonDocument command) { + final BsonValue value = command.get("$db"); + if (value == null || !value.isString()) { + return "test"; + } + return value.asString().getValue(); + } + + private static Long readIntegralLong(final BsonValue value) { + if (value.isInt32()) { + return (long) value.asInt32().getValue(); + } + if (value.isInt64()) { + return value.asInt64().getValue(); + } + if (value.isDouble()) { + final double doubleValue = value.asDouble().getValue(); + if (!Double.isFinite(doubleValue) || Math.rint(doubleValue) != doubleValue) { + return null; + } + if (doubleValue < Long.MIN_VALUE || doubleValue > Long.MAX_VALUE) { + return null; + } + return (long) doubleValue; + } + return null; + } + + private record CursorBatch(int batchSize, BsonDocument error) {} +} diff --git a/src/main/java/org/jongodb/command/UpdateCommandHandler.java b/src/main/java/org/jongodb/command/UpdateCommandHandler.java index a029d7c..0fa689e 100644 --- a/src/main/java/org/jongodb/command/UpdateCommandHandler.java +++ b/src/main/java/org/jongodb/command/UpdateCommandHandler.java @@ -171,7 +171,7 @@ private static BsonDocument validateUpdateDocument( return CommandErrors.badValue("arrayFilters is only supported for operator updates"); } if (multi) { - return CommandErrors.badValue("replacement update requires multi=false"); + return CommandErrors.failedToParse("multi update is not supported for replacement-style update"); } return null; } diff --git a/src/main/java/org/jongodb/engine/EngineStore.java b/src/main/java/org/jongodb/engine/EngineStore.java index d1a1486..6e2838e 100644 --- a/src/main/java/org/jongodb/engine/EngineStore.java +++ b/src/main/java/org/jongodb/engine/EngineStore.java @@ -1,5 +1,7 @@ package org.jongodb.engine; +import java.util.List; + /** * Top-level engine store contract for namespace-scoped collections. */ @@ -17,4 +19,16 @@ default CollectionStore collection(String database, String collection) { default boolean collectionExists(final String database, final String collection) { return collectionExists(Namespace.of(database, collection)); } + + default List listCollectionNames(final String database) { + return List.of(); + } + + default boolean dropCollection(final String database, final String collection) { + return false; + } + + default int dropDatabase(final String database) { + return 0; + } } diff --git a/src/main/java/org/jongodb/engine/InMemoryCollectionStore.java b/src/main/java/org/jongodb/engine/InMemoryCollectionStore.java index 763de8c..b992b48 100644 --- a/src/main/java/org/jongodb/engine/InMemoryCollectionStore.java +++ b/src/main/java/org/jongodb/engine/InMemoryCollectionStore.java @@ -1,7 +1,10 @@ package org.jongodb.engine; +import java.time.Clock; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; +import java.util.Date; import java.util.HashMap; import java.util.IdentityHashMap; import java.util.Iterator; @@ -19,8 +22,14 @@ public final class InMemoryCollectionStore implements CollectionStore { private final List documents = new ArrayList<>(); private final Map indexesByName = new LinkedHashMap<>(); + private final Clock clock; InMemoryCollectionStore() { + this(Clock.systemUTC()); + } + + InMemoryCollectionStore(final Clock clock) { + this.clock = Objects.requireNonNull(clock, "clock"); final Document idIndexKey = new Document("_id", 1); indexesByName.put( "_id_", @@ -28,6 +37,7 @@ public final class InMemoryCollectionStore implements CollectionStore { } private InMemoryCollectionStore(final InMemoryCollectionStore source) { + this.clock = source.clock; for (final Document sourceDocument : source.documents) { this.documents.add(DocumentCopies.copy(sourceDocument)); } @@ -50,10 +60,12 @@ private InMemoryCollectionStore(final InMemoryCollectionStore source) { } synchronized InMemoryCollectionStore snapshot() { + pruneExpiredDocuments(); return new InMemoryCollectionStore(this); } synchronized CollectionState snapshotState() { + pruneExpiredDocuments(); return new CollectionState(copyDocuments(documents), listIndexes()); } @@ -120,6 +132,7 @@ static CollectionState mergeTransactionState( @Override public synchronized void insertMany(List documents) { Objects.requireNonNull(documents, "documents"); + pruneExpiredDocuments(); List copiedDocuments = new ArrayList<>(documents.size()); for (Document document : documents) { @@ -140,6 +153,7 @@ public synchronized void insertMany(List documents) { @Override public synchronized CreateIndexesResult createIndexes(List indexes) { Objects.requireNonNull(indexes, "indexes"); + pruneExpiredDocuments(); final int numIndexesBefore = indexesByName.size(); final Map candidateIndexes = new LinkedHashMap<>(indexesByName); @@ -180,6 +194,7 @@ public synchronized CreateIndexesResult createIndexes(List inde @Override public synchronized List listIndexes() { + pruneExpiredDocuments(); final List listed = new ArrayList<>(indexesByName.size()); for (final IndexMetadata metadata : indexesByName.values()) { listed.add(new IndexDefinition( @@ -196,11 +211,13 @@ public synchronized List listIndexes() { @Override public synchronized List findAll() { + pruneExpiredDocuments(); return copyMatchingDocuments(new Document(), CollationSupport.Config.simple()); } @Override public synchronized Iterable scanAll() { + pruneExpiredDocuments(); final List snapshot = List.copyOf(documents); return () -> new Iterator<>() { private int index = 0; @@ -227,6 +244,7 @@ public synchronized List find(Document filter) { @Override public synchronized List find(final Document filter, final CollationSupport.Config collation) { + pruneExpiredDocuments(); final Document effectiveFilter = filter == null ? new Document() : filter; final CollationSupport.Config effectiveCollation = collation == null ? CollationSupport.Config.simple() : collation; @@ -236,6 +254,7 @@ public synchronized List find(final Document filter, final CollationSu @Override public synchronized List aggregate(final List pipeline) { Objects.requireNonNull(pipeline, "pipeline"); + pruneExpiredDocuments(); final List copiedPipeline = new ArrayList<>(pipeline.size()); for (final Document stage : pipeline) { @@ -265,6 +284,7 @@ public synchronized UpdateManyResult update( final boolean multi, final boolean upsert, final List arrayFilters) { + pruneExpiredDocuments(); final Document effectiveFilter = filter == null ? new Document() : DocumentCopies.copy(filter); final Document effectiveUpdate = update == null ? null : DocumentCopies.copy(update); final List effectiveArrayFilters = copyArrayFilters(arrayFilters); @@ -340,6 +360,7 @@ private static List copyArrayFilters(final List arrayFilters @Override public synchronized DeleteManyResult deleteMany(Document filter) { + pruneExpiredDocuments(); Document effectiveFilter = filter == null ? new Document() : DocumentCopies.copy(filter); long deletedCount = 0; @@ -365,6 +386,52 @@ private List copyMatchingDocuments(final Document filter, final Collat return matches; } + private void pruneExpiredDocuments() { + final List ttlRules = ttlRules(); + if (ttlRules.isEmpty() || documents.isEmpty()) { + return; + } + + final long nowMillis = clock.millis(); + final Iterator iterator = documents.iterator(); + while (iterator.hasNext()) { + final Document document = iterator.next(); + for (final TtlRule ttlRule : ttlRules) { + if (ttlRule.isExpired(document, nowMillis)) { + iterator.remove(); + break; + } + } + } + } + + private List ttlRules() { + final List rules = new ArrayList<>(); + for (final IndexMetadata index : indexesByName.values()) { + if (index.expireAfterSeconds() == null + || index.key().size() != 1 + || index.partialFilterExpression() != null) { + continue; + } + final String fieldPath = index.key().keySet().iterator().next(); + if (fieldPath == null || fieldPath.isBlank()) { + continue; + } + rules.add(new TtlRule(fieldPath, index.expireAfterSeconds() * 1000L)); + } + return rules; + } + + private static Long dateLikeMillis(final Object value) { + if (value instanceof Date dateValue) { + return dateValue.getTime(); + } + if (value instanceof Instant instantValue) { + return instantValue.toEpochMilli(); + } + return null; + } + private UpdateManyResult applyUpsert(final Document filter, final UpdateApplier.ParsedUpdate parsedUpdate) { final Document seed = upsertSeed(filter); final Document upsertedDocument = DocumentCopies.copy(seed); @@ -792,6 +859,13 @@ private static Map toIndexMetadataMap(final List documents, List indexes) { CollectionState { documents = documents == null ? List.of() : copyDocuments(documents); diff --git a/src/main/java/org/jongodb/engine/InMemoryEngineStore.java b/src/main/java/org/jongodb/engine/InMemoryEngineStore.java index 8cfb2d8..c001c34 100644 --- a/src/main/java/org/jongodb/engine/InMemoryEngineStore.java +++ b/src/main/java/org/jongodb/engine/InMemoryEngineStore.java @@ -1,6 +1,8 @@ package org.jongodb.engine; +import java.time.Clock; import java.util.HashSet; +import java.util.List; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; @@ -13,11 +15,20 @@ */ public final class InMemoryEngineStore implements EngineStore { private final ConcurrentMap collections = new ConcurrentHashMap<>(); + private final Clock clock; + + public InMemoryEngineStore() { + this(Clock.systemUTC()); + } + + public InMemoryEngineStore(final Clock clock) { + this.clock = Objects.requireNonNull(clock, "clock"); + } @Override public CollectionStore collection(Namespace namespace) { Objects.requireNonNull(namespace, "namespace"); - return collections.computeIfAbsent(namespace, key -> new InMemoryCollectionStore()); + return collections.computeIfAbsent(namespace, key -> new InMemoryCollectionStore(clock)); } @Override @@ -26,8 +37,35 @@ public boolean collectionExists(final Namespace namespace) { return collections.containsKey(namespace); } + @Override + public synchronized List listCollectionNames(final String database) { + Objects.requireNonNull(database, "database"); + return collections.keySet().stream() + .filter(namespace -> namespace.database().equals(database)) + .map(Namespace::collection) + .sorted() + .toList(); + } + + @Override + public synchronized boolean dropCollection(final String database, final String collection) { + return collections.remove(Namespace.of(database, collection)) != null; + } + + @Override + public synchronized int dropDatabase(final String database) { + Objects.requireNonNull(database, "database"); + int dropped = 0; + for (final Namespace namespace : List.copyOf(collections.keySet())) { + if (namespace.database().equals(database) && collections.remove(namespace) != null) { + dropped++; + } + } + return dropped; + } + public synchronized InMemoryEngineStore snapshot() { - final InMemoryEngineStore snapshot = new InMemoryEngineStore(); + final InMemoryEngineStore snapshot = new InMemoryEngineStore(clock); for (final var entry : collections.entrySet()) { snapshot.collections.put(entry.getKey(), entry.getValue().snapshot()); } @@ -68,7 +106,8 @@ public void mergeTransactionSnapshot( final InMemoryCollectionStore.CollectionState currentState = currentStates.get(namespace); final InMemoryCollectionStore.CollectionState mergedState = InMemoryCollectionStore.mergeTransactionState(baselineState, transactionState, currentState); - collections.computeIfAbsent(namespace, key -> new InMemoryCollectionStore()).replaceState(mergedState); + collections.computeIfAbsent(namespace, key -> new InMemoryCollectionStore(clock)) + .replaceState(mergedState); } } } diff --git a/src/main/java/org/jongodb/testkit/UnifiedSpecImporter.java b/src/main/java/org/jongodb/testkit/UnifiedSpecImporter.java index 4a35a47..87a0434 100644 --- a/src/main/java/org/jongodb/testkit/UnifiedSpecImporter.java +++ b/src/main/java/org/jongodb/testkit/UnifiedSpecImporter.java @@ -31,8 +31,10 @@ public final class UnifiedSpecImporter { "ping", "ping", "buildinfo", "buildInfo", "listindexes", "listIndexes", - // listCollections is currently treated as a deterministic control subset. - "listcollections", "ping", + "listcollections", "listCollections", + "distinct", "distinct", + "drop", "drop", + "dropdatabase", "dropDatabase", "insert", "insert", "find", "find", "count", "count"); diff --git a/src/main/java/org/jongodb/txn/TransactionCommandValidator.java b/src/main/java/org/jongodb/txn/TransactionCommandValidator.java index 49a7aa4..d581ef1 100644 --- a/src/main/java/org/jongodb/txn/TransactionCommandValidator.java +++ b/src/main/java/org/jongodb/txn/TransactionCommandValidator.java @@ -24,6 +24,7 @@ public final class TransactionCommandValidator { "update", "delete", "bulkwrite", + "createindexes", "find", "distinct", "aggregate", diff --git a/src/test/java/org/jongodb/command/CommandDispatcherE2ETest.java b/src/test/java/org/jongodb/command/CommandDispatcherE2ETest.java index c109d52..b2109eb 100644 --- a/src/test/java/org/jongodb/command/CommandDispatcherE2ETest.java +++ b/src/test/java/org/jongodb/command/CommandDispatcherE2ETest.java @@ -1470,7 +1470,7 @@ void updateCommandRejectsInvalidPayloadShapes() { final BsonDocument replacementMultiTrue = dispatcher.dispatch( BsonDocument.parse("{\"update\":\"users\",\"updates\":[{\"q\":{},\"u\":{\"name\":\"a\"},\"multi\":true}]}")); - assertCommandError(replacementMultiTrue, "BadValue"); + assertCommandError(replacementMultiTrue, 9, "FailedToParse"); } @Test diff --git a/src/test/java/org/jongodb/command/CommandLifecycleCommandE2ETest.java b/src/test/java/org/jongodb/command/CommandLifecycleCommandE2ETest.java new file mode 100644 index 0000000..71e08aa --- /dev/null +++ b/src/test/java/org/jongodb/command/CommandLifecycleCommandE2ETest.java @@ -0,0 +1,75 @@ +package org.jongodb.command; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.bson.BsonArray; +import org.bson.BsonDocument; +import org.jongodb.engine.InMemoryEngineStore; +import org.junit.jupiter.api.Test; + +final class CommandLifecycleCommandE2ETest { + @Test + void listCollectionsReturnsCursorBatchWithCollectionMetadata() { + final CommandDispatcher dispatcher = new CommandDispatcher(new EngineBackedCommandStore(new InMemoryEngineStore())); + dispatcher.dispatch(BsonDocument.parse( + "{\"insert\":\"users\",\"$db\":\"app\",\"documents\":[{\"_id\":1}]}")); + dispatcher.dispatch(BsonDocument.parse( + "{\"insert\":\"orders\",\"$db\":\"app\",\"documents\":[{\"_id\":1}]}")); + + final BsonDocument response = dispatcher.dispatch(BsonDocument.parse( + "{\"listCollections\":1,\"$db\":\"app\",\"filter\":{\"name\":\"users\"},\"nameOnly\":true}")); + + assertEquals(1.0, response.get("ok").asNumber().doubleValue()); + final BsonDocument cursor = response.getDocument("cursor"); + assertEquals("app.$cmd.listCollections", cursor.getString("ns").getValue()); + final BsonArray firstBatch = cursor.getArray("firstBatch"); + assertEquals(1, firstBatch.size()); + assertEquals("users", firstBatch.get(0).asDocument().getString("name").getValue()); + assertEquals("collection", firstBatch.get(0).asDocument().getString("type").getValue()); + } + + @Test + void dropRemovesCollectionAndReportsIndexCount() { + final CommandDispatcher dispatcher = new CommandDispatcher(new EngineBackedCommandStore(new InMemoryEngineStore())); + dispatcher.dispatch(BsonDocument.parse( + "{\"insert\":\"users\",\"$db\":\"app\",\"documents\":[{\"_id\":1,\"email\":\"a@example.com\"}]}")); + dispatcher.dispatch(BsonDocument.parse( + "{\"createIndexes\":\"users\",\"$db\":\"app\",\"indexes\":[{\"name\":\"email_1\",\"key\":{\"email\":1}}]}")); + + final BsonDocument dropResponse = + dispatcher.dispatch(BsonDocument.parse("{\"drop\":\"users\",\"$db\":\"app\"}")); + + assertEquals(1.0, dropResponse.get("ok").asNumber().doubleValue()); + assertEquals("app.users", dropResponse.getString("ns").getValue()); + assertEquals(2, dropResponse.getInt32("nIndexesWas").getValue()); + + final BsonDocument listResponse = + dispatcher.dispatch(BsonDocument.parse("{\"listCollections\":1,\"$db\":\"app\"}")); + assertEquals(0, listResponse.getDocument("cursor").getArray("firstBatch").size()); + } + + @Test + void dropDatabaseRemovesAllCollectionsInDatabaseOnly() { + final CommandDispatcher dispatcher = new CommandDispatcher(new EngineBackedCommandStore(new InMemoryEngineStore())); + dispatcher.dispatch(BsonDocument.parse( + "{\"insert\":\"users\",\"$db\":\"app\",\"documents\":[{\"_id\":1}]}")); + dispatcher.dispatch(BsonDocument.parse( + "{\"insert\":\"orders\",\"$db\":\"app\",\"documents\":[{\"_id\":1}]}")); + dispatcher.dispatch(BsonDocument.parse( + "{\"insert\":\"users\",\"$db\":\"other\",\"documents\":[{\"_id\":1}]}")); + + final BsonDocument dropResponse = + dispatcher.dispatch(BsonDocument.parse("{\"dropDatabase\":1,\"$db\":\"app\"}")); + + assertEquals(1.0, dropResponse.get("ok").asNumber().doubleValue()); + assertEquals("app", dropResponse.getString("dropped").getValue()); + + final BsonDocument appCollections = + dispatcher.dispatch(BsonDocument.parse("{\"listCollections\":1,\"$db\":\"app\"}")); + assertEquals(0, appCollections.getDocument("cursor").getArray("firstBatch").size()); + + final BsonDocument otherCollections = + dispatcher.dispatch(BsonDocument.parse("{\"listCollections\":1,\"$db\":\"other\"}")); + assertEquals(1, otherCollections.getDocument("cursor").getArray("firstBatch").size()); + } +} diff --git a/src/test/java/org/jongodb/command/DeterministicR3ParityFixTest.java b/src/test/java/org/jongodb/command/DeterministicR3ParityFixTest.java new file mode 100644 index 0000000..48f0981 --- /dev/null +++ b/src/test/java/org/jongodb/command/DeterministicR3ParityFixTest.java @@ -0,0 +1,58 @@ +package org.jongodb.command; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.bson.BsonDocument; +import org.jongodb.engine.InMemoryEngineStore; +import org.junit.jupiter.api.Test; + +final class DeterministicR3ParityFixTest { + @Test + void insertRejectsIdDocumentWithDollarPrefixedField() { + final CommandDispatcher dispatcher = new CommandDispatcher(new EngineBackedCommandStore(new InMemoryEngineStore())); + + final BsonDocument response = dispatcher.dispatch(BsonDocument.parse( + "{\"insert\":\"users\",\"$db\":\"app\",\"documents\":[{\"_id\":{\"$a\":1},\"name\":\"bad\"}]}")); + + assertEquals(0.0, response.get("ok").asNumber().doubleValue()); + assertEquals(52, response.getInt32("code").getValue()); + assertEquals("DollarPrefixedFieldName", response.getString("codeName").getValue()); + assertTrue(response.getString("errmsg") + .getValue() + .contains("_id fields may not contain '$'-prefixed fields: $a is not valid for storage")); + } + + @Test + void updateRejectsMultiReplacementStyleUpdateAsFailedToParse() { + final CommandDispatcher dispatcher = new CommandDispatcher(new EngineBackedCommandStore(new InMemoryEngineStore())); + + final BsonDocument response = dispatcher.dispatch(BsonDocument.parse( + "{\"update\":\"users\",\"$db\":\"app\",\"updates\":[{\"q\":{},\"u\":{\"name\":\"replacement\"},\"multi\":true}]}")); + + assertEquals(0.0, response.get("ok").asNumber().doubleValue()); + assertEquals(9, response.getInt32("code").getValue()); + assertEquals("FailedToParse", response.getString("codeName").getValue()); + assertEquals( + "multi update is not supported for replacement-style update", + response.getString("errmsg").getValue()); + } + + @Test + void createIndexesCanStartAndCommitTransaction() { + final CommandDispatcher dispatcher = new CommandDispatcher(new EngineBackedCommandStore(new InMemoryEngineStore())); + + final BsonDocument startResponse = dispatcher.dispatch(BsonDocument.parse( + "{\"createIndexes\":\"users\",\"$db\":\"app\",\"indexes\":[{\"name\":\"email_1\",\"key\":{\"email\":1}}],\"lsid\":{\"id\":\"session-createindexes\"},\"txnNumber\":1,\"autocommit\":false,\"startTransaction\":true}")); + assertEquals(1.0, startResponse.get("ok").asNumber().doubleValue()); + + final BsonDocument commitResponse = dispatcher.dispatch(BsonDocument.parse( + "{\"commitTransaction\":1,\"$db\":\"app\",\"lsid\":{\"id\":\"session-createindexes\"},\"txnNumber\":1,\"autocommit\":false}")); + assertEquals(1.0, commitResponse.get("ok").asNumber().doubleValue()); + + final BsonDocument listIndexesResponse = + dispatcher.dispatch(BsonDocument.parse("{\"listIndexes\":\"users\",\"$db\":\"app\"}")); + assertEquals(1.0, listIndexesResponse.get("ok").asNumber().doubleValue()); + assertEquals(2, listIndexesResponse.getDocument("cursor").getArray("firstBatch").size()); + } +} diff --git a/src/test/java/org/jongodb/engine/InMemoryCollectionStoreTtlTest.java b/src/test/java/org/jongodb/engine/InMemoryCollectionStoreTtlTest.java new file mode 100644 index 0000000..f2b7f71 --- /dev/null +++ b/src/test/java/org/jongodb/engine/InMemoryCollectionStoreTtlTest.java @@ -0,0 +1,141 @@ +package org.jongodb.engine; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Date; +import java.util.List; +import org.bson.Document; +import org.junit.jupiter.api.Test; + +class InMemoryCollectionStoreTtlTest { + @Test + void findAllPrunesExpiredDateDocumentsAtExpiryBoundary() { + final MutableClock clock = new MutableClock(Instant.parse("2026-05-09T00:00:10Z")); + final InMemoryCollectionStore store = new InMemoryCollectionStore(clock); + store.createIndexes(List.of(ttlIndex("expiresAt_1", new Document("expiresAt", 1), 10L))); + store.insertMany(List.of( + new Document("_id", 1).append("expiresAt", Date.from(Instant.parse("2026-05-09T00:00:00Z"))), + new Document("_id", 2).append("expiresAt", Date.from(Instant.parse("2026-05-09T00:00:01Z"))))); + + final List remaining = store.findAll(); + + assertEquals(1, remaining.size()); + assertEquals(2, remaining.get(0).getInteger("_id")); + } + + @Test + void pruneSupportsInstantValues() { + final MutableClock clock = new MutableClock(Instant.parse("2026-05-09T00:00:10Z")); + final InMemoryCollectionStore store = new InMemoryCollectionStore(clock); + store.createIndexes(List.of(ttlIndex("expiresAt_1", new Document("expiresAt", 1), 10L))); + store.insertMany(List.of( + new Document("_id", 1).append("expiresAt", Instant.parse("2026-05-09T00:00:00Z")), + new Document("_id", 2).append("expiresAt", Instant.parse("2026-05-09T00:00:01Z")))); + + final List remaining = store.findAll(); + + assertEquals(1, remaining.size()); + assertEquals(2, remaining.get(0).getInteger("_id")); + } + + @Test + void pruneIgnoresTierZeroUnsupportedValuesAndIndexes() { + final MutableClock clock = new MutableClock(Instant.parse("2026-05-09T00:00:10Z")); + final InMemoryCollectionStore store = new InMemoryCollectionStore(clock); + store.createIndexes(List.of( + ttlIndex("expiresAt_1", new Document("expiresAt", 1), 10L), + ttlIndex("compound_1", new Document("compoundAt", 1).append("tenantId", 1), 10L), + new CollectionStore.IndexDefinition( + "partialAt_1", + new Document("partialAt", 1), + false, + false, + new Document("active", true), + null, + 10L))); + store.insertMany(List.of( + new Document("_id", 1), + new Document("_id", 2).append("expiresAt", null), + new Document("_id", 3).append("expiresAt", "2026-05-09T00:00:00Z"), + new Document("_id", 4).append("expiresAt", List.of(Date.from(Instant.parse("2026-05-09T00:00:00Z")))), + new Document("_id", 5).append("compoundAt", Date.from(Instant.parse("2026-05-09T00:00:00Z"))), + new Document("_id", 6).append("partialAt", Date.from(Instant.parse("2026-05-09T00:00:00Z"))), + new Document("_id", 7).append("expiresAt", Date.from(Instant.parse("2026-05-09T00:00:00Z"))))); + + final List remaining = store.findAll(); + + assertEquals(List.of(1, 2, 3, 4, 5, 6), ids(remaining)); + } + + @Test + void insertManyPrunesExpiredDocumentsBeforeUniqueValidation() { + final MutableClock clock = new MutableClock(Instant.parse("2026-05-09T00:00:10Z")); + final InMemoryCollectionStore store = new InMemoryCollectionStore(clock); + store.createIndexes(List.of( + ttlIndex("expiresAt_1", new Document("expiresAt", 1), 10L), + new CollectionStore.IndexDefinition("email_1", new Document("email", 1), true))); + store.insertMany(List.of(new Document("_id", 1) + .append("email", "ada@example.com") + .append("expiresAt", Date.from(Instant.parse("2026-05-09T00:00:00Z"))))); + + store.insertMany(List.of(new Document("_id", 2).append("email", "ada@example.com"))); + + final List remaining = store.findAll(); + assertEquals(1, remaining.size()); + assertEquals(2, remaining.get(0).getInteger("_id")); + } + + @Test + void engineSnapshotsCarryInjectedClock() { + final MutableClock clock = new MutableClock(Instant.parse("2026-05-09T00:00:09Z")); + final InMemoryEngineStore engine = new InMemoryEngineStore(clock); + final CollectionStore collection = engine.collection("app", "sessions"); + collection.createIndexes(List.of(ttlIndex("expiresAt_1", new Document("expiresAt", 1), 10L))); + collection.insertMany(List.of(new Document("_id", 1) + .append("expiresAt", Date.from(Instant.parse("2026-05-09T00:00:00Z"))))); + + final InMemoryEngineStore snapshot = engine.snapshot(); + clock.setInstant(Instant.parse("2026-05-09T00:00:10Z")); + + assertEquals(0, snapshot.collection("app", "sessions").findAll().size()); + } + + private static CollectionStore.IndexDefinition ttlIndex( + final String name, final Document key, final Long expireAfterSeconds) { + return new CollectionStore.IndexDefinition(name, key, false, false, null, expireAfterSeconds); + } + + private static List ids(final List documents) { + return documents.stream().map(document -> document.getInteger("_id")).toList(); + } + + private static final class MutableClock extends Clock { + private Instant instant; + + private MutableClock(final Instant instant) { + this.instant = instant; + } + + private void setInstant(final Instant instant) { + this.instant = instant; + } + + @Override + public ZoneId getZone() { + return ZoneId.of("UTC"); + } + + @Override + public Clock withZone(final ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return instant; + } + } +} diff --git a/src/test/java/org/jongodb/testkit/UnifiedSpecImporterTest.java b/src/test/java/org/jongodb/testkit/UnifiedSpecImporterTest.java index 8431d37..54531cf 100644 --- a/src/test/java/org/jongodb/testkit/UnifiedSpecImporterTest.java +++ b/src/test/java/org/jongodb/testkit/UnifiedSpecImporterTest.java @@ -1,6 +1,7 @@ package org.jongodb.testkit; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; @@ -1894,7 +1895,7 @@ void importsAssertIndexExistsAsNoOpSubset() throws IOException { } @Test - void importsRunCommandListCollectionsAsDeterministicPingSubset() throws IOException { + void importsRunCommandListCollectionsSubset() throws IOException { Files.writeString( tempDir.resolve("run-command-listcollections-subset.json"), """ @@ -1903,7 +1904,7 @@ void importsRunCommandListCollectionsAsDeterministicPingSubset() throws IOExcept "collection_name": "users", "tests": [ { - "description": "runCommand listCollections deterministic subset", + "description": "runCommand listCollections subset", "operations": [ {"name": "runCommand", "arguments": {"command": {"listCollections": 1}}} ] @@ -1919,11 +1920,12 @@ void importsRunCommandListCollectionsAsDeterministicPingSubset() throws IOExcept final Scenario scenario = result.importedScenarios().get(0).scenario(); assertEquals(1, scenario.commands().size()); - assertEquals("ping", scenario.commands().get(0).commandName()); + assertEquals("listCollections", scenario.commands().get(0).commandName()); final WireCommandIngressBackend backend = new WireCommandIngressBackend("wire"); final ScenarioOutcome outcome = backend.execute(scenario); assertTrue(outcome.success(), outcome.errorMessage().orElse("expected success")); + assertTrue(outcome.commandResults().get(0).containsKey("cursor")); } @Test @@ -1966,6 +1968,54 @@ void importsRunCommandBuildInfoAndListIndexesSubset() throws IOException { assertTrue(outcome.commandResults().get(2).containsKey("cursor")); } + @Test + void importsRunCommandDistinctAndLifecycleSubset() throws IOException { + Files.writeString( + tempDir.resolve("run-command-distinct-lifecycle.json"), + """ + { + "database_name": "app", + "collection_name": "users", + "tests": [ + { + "description": "runCommand distinct and lifecycle subset", + "operations": [ + {"name": "insertMany", "arguments": {"documents": [ + {"_id": 1, "tag": "a"}, + {"_id": 2, "tag": "a"}, + {"_id": 3, "tag": "b"} + ]}}, + {"name": "runCommand", "arguments": {"command": {"distinct": "users", "key": "tag"}}}, + {"name": "runCommand", "arguments": {"command": {"drop": "users"}}}, + {"name": "runCommand", "arguments": {"command": {"dropDatabase": 1}}} + ] + } + ] + } + """); + + final UnifiedSpecImporter importer = new UnifiedSpecImporter(); + final UnifiedSpecImporter.ImportResult result = importer.importCorpus(tempDir); + assertEquals(1, result.importedCount()); + assertEquals(0, result.unsupportedCount()); + + final Scenario scenario = result.importedScenarios().get(0).scenario(); + assertEquals(4, scenario.commands().size()); + assertEquals("distinct", scenario.commands().get(1).commandName()); + assertEquals("drop", scenario.commands().get(2).commandName()); + assertEquals("dropDatabase", scenario.commands().get(3).commandName()); + + final WireCommandIngressBackend backend = new WireCommandIngressBackend("wire"); + final ScenarioOutcome outcome = backend.execute(scenario); + assertTrue(outcome.success(), outcome.errorMessage().orElse("expected success")); + + @SuppressWarnings("unchecked") + final List values = (List) outcome.commandResults().get(1).get("values"); + assertEquals(List.of("a", "b"), values); + assertEquals("app.users", outcome.commandResults().get(2).get("ns")); + assertEquals("app", outcome.commandResults().get(3).get("dropped")); + } + @Test void importsCreateIndexCommitQuorumOption() throws IOException { Files.writeString( @@ -2402,10 +2452,10 @@ void importsInsertOneWithDollarPrefixedKeyInIdDocument() throws IOException { final WireCommandIngressBackend backend = new WireCommandIngressBackend("wire"); final ScenarioOutcome outcome = backend.execute(scenario); - assertTrue(outcome.success(), outcome.errorMessage().orElse("expected success")); - final Map countResult = outcome.commandResults().get(1); - assertEquals(1L, ((Number) countResult.get("n")).longValue()); - assertEquals(1L, ((Number) countResult.get("count")).longValue()); + assertFalse(outcome.success()); + assertTrue(outcome.errorMessage() + .orElse("") + .contains("_id fields may not contain '$'-prefixed fields: $a is not valid for storage")); } @Test